feat(mcp): allow native MCP OAuth support for cursor (#28327)

* feat(mcp): allow native MCP OAuth redirect URIs (cursor://)

Discoverable OAuth /authorize rejected cursor:// callbacks because
validate_trusted_redirect_uri only accepted http/https. Add an
allowlisted native path with a built-in Cursor default and optional
MCP_TRUSTED_NATIVE_REDIRECT_URIS env for other clients.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): address Greptile native redirect URI review

Lowercase paths in normalizer so env allowlist entries match case-
insensitively. Tighten wildcard prefix matching to reject sibling
paths (e.g. callback-2) unless the prefix ends with /.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): reject query params on native OAuth redirect URIs

Greptile: normalization stripped query strings before allowlist compare,
so cursor://.../callback?injected=... could pass validation. Reject any
native redirect_uri with a query component (same as fragments).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(model_cost_map): add mistral/ministral-8b-2512 entry

Mistral rotated the 'mistral/mistral-tiny' alias to return
'ministral-8b-2512' as the response model, which is not in the cost map.
This caused test_completion_mistral_api and
test_completion_mistral_api_modified_input to fail in
completion_cost lookup. Add the entry mirroring the existing
openrouter/mistralai/ministral-8b-2512 pricing.

* fix(mcp): lowercase default native redirect URIs

Make _parse_trusted_native_redirect_uris apply the same lowercasing
to built-in defaults as it does to env-var entries.

* fix(tests): backfill local model_cost into remote-fetched map

litellm.model_cost is loaded at import time from the URL pinned to main,
so pricing entries that exist only in this branch (e.g.
mistral/ministral-8b-2512, freshly added because Mistral now returns this
id from mistral-tiny) are absent at test time and completion_cost lookups
raise. Backfill the in-tree backup so cassette-driven cost calculations
resolve against the entries that ship with the branch under test.

Fixes the local_testing_part1 failures on test_completion_mistral_api and
test_completion_mistral_api_modified_input.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Co-authored-by: Claude <claude@anthropic.com>
This commit is contained in:
Sameer Kankute 2026-05-21 03:58:44 +05:30 • committed by lorenzbaraldi
parent 06bbe3aa04
commit 4da0592283
2 changed files with 228 additions and 3 deletions

View file

@ -29,6 +29,16 @@ _DEFAULT_PORTS = {"http": 80, "https": 443}
# subdomain. HTTPS only.
_TRUSTED_REDIRECT_ORIGINS_ENV = "MCP_TRUSTED_REDIRECT_ORIGINS"
# Comma-separated private-use URI allowlist for native MCP clients.
# A trailing ``*`` is a prefix match; end the prefix with ``/`` (e.g.
# ``myapp://host/oauth/*``) so ``.../oauth/callback*`` does not also
# match ``.../oauth/callback-2``.
_TRUSTED_NATIVE_REDIRECT_URIS_ENV = "MCP_TRUSTED_NATIVE_REDIRECT_URIS"
# Default allowlist for trusted native redirect URIs.
_DEFAULT_NATIVE_REDIRECT_URIS: List[str] = [
"cursor://anysphere.cursor-mcp/oauth/callback",
]
_warned_invalid_proxy_base_url: Optional[str] = None
@ -212,10 +222,82 @@ def _matches_trusted_origin_entry(netloc: str, entry: str) -> bool:
return netloc == entry
def _normalize_native_redirect_uri(
parsed,
) -> str:
"""Lowercase scheme, netloc, and path for allowlist comparison."""
return urlunparse(
(
(parsed.scheme or "").lower(),
(parsed.netloc or "").lower(),
(parsed.path or "").lower(),
"",
"",
"",
)
)
def _parse_trusted_native_redirect_uris() -> List[str]:
"""Built-in native MCP callbacks plus ``MCP_TRUSTED_NATIVE_REDIRECT_URIS``."""
entries: List[str] = [uri.lower() for uri in _DEFAULT_NATIVE_REDIRECT_URIS]
raw = os.environ.get(_TRUSTED_NATIVE_REDIRECT_URIS_ENV, "").strip()
if not raw:
return entries
for token in raw.split(","):
entry = token.strip().lower()
if entry and entry not in entries:
entries.append(entry)
return entries
def _native_wildcard_prefix_matches(normalized: str, prefix: str) -> bool:
"""Prefix match for ``entry*`` allowlist rows.
When the prefix does not end with ``/``, only exact matches or
deeper path segments (``prefix/...``) are accepted — not siblings
like ``prefix-2``.
"""
if not normalized.startswith(prefix):
return False
suffix = normalized[len(prefix) :]
if not suffix:
return True
if prefix.endswith("/"):
return True
return suffix[0] == "/"
def _matches_trusted_native_redirect_uri(parsed) -> bool:
"""Allowlisted private-use / custom-scheme OAuth callbacks for native MCP clients."""
if parsed.fragment:
return False
# Query strings are not part of registered redirect_uris (RFC 6749 §3.1.2).
# Rejecting them prevents allowlist bypass via ``.../callback?injected=...``.
if parsed.query:
return False
if not parsed.netloc:
return False
if parsed.username is not None or parsed.password is not None:
return False
if "\\" in parsed.netloc:
return False
normalized = _normalize_native_redirect_uri(parsed)
for entry in _parse_trusted_native_redirect_uris():
if entry.endswith("*"):
if _native_wildcard_prefix_matches(normalized, entry[:-1]):
return True
elif normalized == entry:
return True
return False
def validate_trusted_redirect_uri(request: Request, redirect_uri: str) -> None:
"""Accept ``redirect_uri`` when it is (a) same-origin with the
proxy's own request origin, (b) loopback, or (c) listed in the
``MCP_TRUSTED_REDIRECT_ORIGINS`` ops allowlist.
proxy's own request origin, (b) loopback, (c) listed in the
``MCP_TRUSTED_REDIRECT_ORIGINS`` ops allowlist, or (d) a built-in /
env-configured native MCP client callback (e.g. ``cursor://``).
Same-origin is VERIA-57's threat-model-safe equivalent of loopback:
an attacker who can host content on the proxy's own HTTPS origin
@ -239,6 +321,8 @@ def validate_trusted_redirect_uri(request: Request, redirect_uri: str) -> None:
except ValueError:
raise HTTPException(status_code=400, detail="invalid_request")
if parsed.scheme not in ("http", "https"):
if _matches_trusted_native_redirect_uri(parsed):
return
raise HTTPException(status_code=400, detail="invalid_request")
if parsed.fragment:
raise HTTPException(status_code=400, detail="invalid_request")
@ -310,9 +394,12 @@ def validate_trusted_redirect_uri(request: Request, redirect_uri: str) -> None:
"Inbound headers: X-Forwarded-Proto=%r X-Forwarded-Host=%r "
"X-Forwarded-Port=%r Host=%r. "
"Trusted-redirect-origins env=%r. "
"Trusted-native-redirect-uris env=%r. "
"If this should be accepted, either align ingress X-Forwarded-* "
"with the browser URL, set PROXY_BASE_URL to your public origin, "
"or add the redirect_uri host to MCP_TRUSTED_REDIRECT_ORIGINS.",
"add the redirect_uri host to MCP_TRUSTED_REDIRECT_ORIGINS, or "
"for native MCP clients (cursor://, etc.) add the full redirect_uri "
"to MCP_TRUSTED_NATIVE_REDIRECT_URIS.",
redirect_uri,
proxy_base,
os.environ.get("PROXY_BASE_URL"),
@ -321,5 +408,6 @@ def validate_trusted_redirect_uri(request: Request, redirect_uri: str) -> None:
request.headers.get("X-Forwarded-Port"),
request.headers.get("Host"),
os.environ.get(_TRUSTED_REDIRECT_ORIGINS_ENV),
os.environ.get(_TRUSTED_NATIVE_REDIRECT_URIS_ENV),
)
raise HTTPException(status_code=400, detail="invalid_request")

View file

@ -1229,6 +1229,143 @@ def test_validate_trusted_redirect_uri_rejects_fragment_and_bad_scheme():
assert exc.value.status_code == 400, uri
def test_validate_trusted_redirect_uri_accepts_cursor_native_callback():
from litellm.proxy._experimental.mcp_server.oauth_utils import (
validate_trusted_redirect_uri,
)
req = _make_trusted_request("http://localhost:4000/")
validate_trusted_redirect_uri(req, "cursor://anysphere.cursor-mcp/oauth/callback")
def test_validate_trusted_redirect_uri_rejects_unlisted_native_callback(
monkeypatch,
):
from litellm.proxy._experimental.mcp_server.oauth_utils import (
validate_trusted_redirect_uri,
)
monkeypatch.setenv("MCP_TRUSTED_NATIVE_REDIRECT_URIS", "")
# Clear defaults by patching — env-only path for this test
monkeypatch.setattr(
"litellm.proxy._experimental.mcp_server.oauth_utils._DEFAULT_NATIVE_REDIRECT_URIS",
[],
)
req = _make_trusted_request("http://localhost:4000/")
with pytest.raises(HTTPException) as exc:
validate_trusted_redirect_uri(
req, "cursor://anysphere.cursor-mcp/oauth/callback"
)
assert exc.value.status_code == 400
def test_validate_trusted_redirect_uri_accepts_env_native_redirect_uri(
monkeypatch,
):
from litellm.proxy._experimental.mcp_server.oauth_utils import (
validate_trusted_redirect_uri,
)
monkeypatch.setattr(
"litellm.proxy._experimental.mcp_server.oauth_utils._DEFAULT_NATIVE_REDIRECT_URIS",
[],
)
monkeypatch.setenv(
"MCP_TRUSTED_NATIVE_REDIRECT_URIS",
"vscode://my-app/oauth/callback",
)
req = _make_trusted_request("http://localhost:4000/")
validate_trusted_redirect_uri(req, "vscode://my-app/oauth/callback")
def test_validate_trusted_redirect_uri_rejects_native_callback_with_fragment():
from litellm.proxy._experimental.mcp_server.oauth_utils import (
validate_trusted_redirect_uri,
)
req = _make_trusted_request("http://localhost:4000/")
with pytest.raises(HTTPException) as exc:
validate_trusted_redirect_uri(
req, "cursor://anysphere.cursor-mcp/oauth/callback#frag"
)
assert exc.value.status_code == 400
def test_validate_trusted_redirect_uri_rejects_native_callback_with_query():
from litellm.proxy._experimental.mcp_server.oauth_utils import (
validate_trusted_redirect_uri,
)
req = _make_trusted_request("http://localhost:4000/")
with pytest.raises(HTTPException) as exc:
validate_trusted_redirect_uri(
req,
"cursor://anysphere.cursor-mcp/oauth/callback?injected=anything",
)
assert exc.value.status_code == 400
def test_validate_trusted_redirect_uri_native_path_case_insensitive(monkeypatch):
from litellm.proxy._experimental.mcp_server.oauth_utils import (
validate_trusted_redirect_uri,
)
monkeypatch.setattr(
"litellm.proxy._experimental.mcp_server.oauth_utils._DEFAULT_NATIVE_REDIRECT_URIS",
[],
)
monkeypatch.setenv(
"MCP_TRUSTED_NATIVE_REDIRECT_URIS",
"myapp://host/MyPath",
)
req = _make_trusted_request("http://localhost:4000/")
validate_trusted_redirect_uri(req, "myapp://host/MyPath")
def test_validate_trusted_redirect_uri_native_wildcard_respects_path_boundary(
monkeypatch,
):
from litellm.proxy._experimental.mcp_server.oauth_utils import (
validate_trusted_redirect_uri,
)
monkeypatch.setattr(
"litellm.proxy._experimental.mcp_server.oauth_utils._DEFAULT_NATIVE_REDIRECT_URIS",
[],
)
monkeypatch.setenv(
"MCP_TRUSTED_NATIVE_REDIRECT_URIS",
"cursor://anysphere.cursor-mcp/oauth/callback*",
)
req = _make_trusted_request("http://localhost:4000/")
validate_trusted_redirect_uri(
req, "cursor://anysphere.cursor-mcp/oauth/callback/extra"
)
with pytest.raises(HTTPException):
validate_trusted_redirect_uri(
req, "cursor://anysphere.cursor-mcp/oauth/callback-2"
)
def test_validate_trusted_redirect_uri_native_wildcard_directory_prefix(
monkeypatch,
):
from litellm.proxy._experimental.mcp_server.oauth_utils import (
validate_trusted_redirect_uri,
)
monkeypatch.setattr(
"litellm.proxy._experimental.mcp_server.oauth_utils._DEFAULT_NATIVE_REDIRECT_URIS",
[],
)
monkeypatch.setenv(
"MCP_TRUSTED_NATIVE_REDIRECT_URIS",
"cursor://anysphere.cursor-mcp/oauth/*",
)
req = _make_trusted_request("http://localhost:4000/")
validate_trusted_redirect_uri(req, "cursor://anysphere.cursor-mcp/oauth/callback")
def test_validate_trusted_redirect_uri_rejects_scheme_mismatch_on_same_host():
"""Regression: an attacker who can serve http on the proxy's own
host (e.g. by MITMing an unencrypted LAN hop) must not be able to