litellm/tests/mcp_tests/test_mcp_auth_priority.py
tin-berri bbef1b84ab
feat(mcp): graft v2 resolver onto _create_mcp_client (none + api_key static family) (#31058)
* feat(mcp): add v1 bridge + none/api_key resolver arms (unwired)

PR4a of the MCP v2 outbound-credential migration, stacked on the resolver skeleton.
Builds the bridge for the first live modes without wiring it onto the request path:

- resolver.py: the none arm (NoOpAuth) and the api_key shared-key arm (StaticHeaderAuth
  from the config); the BYOK source and the other five arms stay not_implemented.
- adapter.py: the v1 <-> v2 edge (to_subject, to_server_spec, raise_public, should_defer).
  to_server_spec maps only none + the static-header family and returns None to defer every
  other mode to v1. Imports v1, kept out of the package __init__ so the resolver core stays
  v1-free.
- MCPClient gains an optional resolved_auth that feeds the factory's auth= slot, taking
  precedence over the SigV4 aws_auth; default None keeps current behavior.

Nothing calls these from _create_mcp_client yet, so production behavior is unchanged; the
graft lands in PR4b. Unit tests cover the two arms, the full mapping table, and the auth
plumbing.

* feat(mcp): graft v2 resolver onto _create_mcp_client for migrated modes

Wire the none + api_key static-family resolver arms from PR4a onto v1's
live request path. In _create_mcp_client's HTTP/SSE branch, to_server_spec
decides per mode: a migrated mode resolves through the injected
UpstreamCredentialProvider and feeds the resulting httpx.Auth into the new
resolved_auth slot; every other mode returns None and falls through to the
unchanged v1 construction. resolve_mcp_auth now runs only when the mode
defers, so a migrated server skips the v1 token-exchange / M2M I/O.

stdio is untouched: auth_type/auth_value never reach the upstream on the
stdio path (_get_auth_headers is HTTP/SSE only), so there is nothing to
graft there. No v1 code is deleted yet; resolve_mcp_auth's static return
still backs stdio and the not-yet-migrated modes until later PRs retire it.

* test(mcp): cover the v2-resolver graft in _create_mcp_client

Regression tests for the PR4 graft. Migrated HTTP modes resolve through the
provider into resolved_auth: none -> NoOpAuth, and the static api_key family
emits the right header per scheme (X-API-Key, Bearer, token, raw authorization,
base64 basic). Deferred modes (oauth2) and a missing static token fall back to
v1's auth_value. A stdio server with a migrated auth_type still defers to v1,
since httpx.Auth never reaches the subprocess. A resolver Error is mapped to the
public HTTP contract (401) via an injected provider, exercising the DI seam.

* fix(mcp): defer to v1 when an inbound credential would be overridden

The graft attaches the resolved static credential as an httpx.Auth, whose auth
flow writes its header after extra_headers. That silently overrode an inbound
Authorization: a per-request mcp_auth_header override, or a header supplied via a
guardrail hook / static_headers / forwarded caller header. v1 lets those win, so
the graft had inverted the credential precedence for the migrated static modes.

Mirror the v2 egress credential-isolation invariant: defer the request to v1 when
mcp_auth_header is set, or when the header the resolved credential would write is
already present in extra_headers. none writes no header, so it never defers.

* test(mcp): cover the credential-isolation defer guard

Regression tests for the precedence fix. A per-request mcp_auth_header override and an
Authorization already present in extra_headers (guardrail hook like the JWT signer,
static_headers, or a forwarded caller header) both defer a migrated static server to v1
so the inbound credential wins; none stays on v2 and does not clobber an inbound
Authorization since NoOpAuth writes nothing. The deferred cases assert resolved_auth is
None, which fails if the guard is removed.

* refactor(mcp): resolve inbound-header conflict on v2 instead of deferring

For an Authorization already supplied via extra_headers (a guardrail hook such as the
JWT signer, static_headers, or a forwarded caller header), keep the request on the v2
path and skip resolved_auth rather than deferring to v1. The inbound header still wins
since nothing overwrites it, but hooks no longer pin a v1 fallback, which is what lets
resolve_mcp_auth be retired once the remaining modes migrate.

The mcp_auth_header per-request override still defers to v1, since that value becomes
the upstream credential rather than sitting in extra_headers; that defer falls away
once the per-user modes stop writing mcp_auth_header.

* fix(mcp): clear UP037 lint gate and fix allowed-servers test under the graft

adapter.py uses `from __future__ import annotations`, so the quoted "UserAPIKeyAuth" /
"MCPServer" annotations in to_subject/to_server_spec/_shared_key_spec were unnecessary
and pushed UP037 over the strict-rule budget; drop the quotes.

test_list_tools_only_returns_allowed_servers passed a MagicMock as user_api_key_auth.
The graft now builds a Subject from the principal, and the MagicMock's non-string
org_id/user_id fail Subject validation, so the listing came back empty. Use a real
UserAPIKeyAuth instead (MagicMock for an injected dependency was the anti-pattern here).

* test(mcp): assert config token via resolved_auth, not the headers dict

test_mcp_server_config_auth_value_header_used inspected _get_auth_headers(), but the
graft now carries the static credential on the client's httpx.Auth (resolved_auth) and
writes the header at send time, so that dict is empty. Assert the header the
StaticHeaderAuth emits onto the request instead. Both config keys (authentication_token,
auth_value) stay covered.

* chore(typecheck): set reportMatchNotExhaustive slack to 0

The previous slack of 3 put the ceiling at baseline + slack = 4, so a newly
non-exhaustive match (for instance dropping an Error arm off a Result match)
could land without tripping the gate. Setting slack to 0 pins the ceiling at
the current baseline of 1, so any added non-exhaustive match now fails CI while
the one pre-existing violation in router.py stays within budget
2026-06-24 14:53:33 -07:00

78 lines
2.8 KiB
Python

"""
Simple test to validate MCP auth header priority behavior.
Validates that:
1. auth_value is not required in config.yaml
2. Server-specific headers (x-mcp-server-name-authorization) take precedence over config auth_value
"""
import pytest
from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager
from litellm.types.mcp import MCPAuth, MCPTransport, MCPSpecVersion
from litellm.types.mcp_server.mcp_server_manager import MCPServer
@pytest.mark.asyncio
async def test_mcp_server_works_without_config_auth_value():
"""
Test that MCP servers work without auth_value in config when headers are provided.
This validates that auth_value is truly optional in config.yaml.
"""
# Create a server WITHOUT config auth_value
server_without_config_auth = MCPServer(
server_id="test-server-no-config",
name="Test MCP Server No Config Auth",
server_name="test_server_no_config",
alias="test_no_config",
url="https://api.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.authorization,
authentication_token=None, # No config auth
)
manager = MCPServerManager()
# Test that it works with only header auth
client = await manager._create_mcp_client(
server=server_without_config_auth,
mcp_auth_header="Bearer token_from_header_only",
)
# Verify header token is used
assert client._mcp_auth_value == "Bearer token_from_header_only"
assert client.auth_type == MCPAuth.authorization
@pytest.mark.parametrize("token_key", ["authentication_token", "auth_value"])
async def test_mcp_server_config_auth_value_header_used(token_key):
"""Ensure the configured auth token is emitted as the upstream Authorization header.
The token is resolved through the v2 credential resolver and rides on the client's
httpx.Auth, so assert the header it writes onto the request rather than the (now
credential-free) _get_auth_headers() dict.
"""
import httpx
from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
StaticHeaderAuth,
)
config = {
"test_server": {
"url": "https://api.example.com/mcp",
"transport": "http",
"auth_type": "bearer_token",
token_key: "example_token",
}
}
manager = MCPServerManager()
await manager.load_servers_from_config(config)
server = next(iter(manager.config_mcp_servers.values()))
client = await manager._create_mcp_client(server)
assert isinstance(client._resolved_auth, StaticHeaderAuth)
emitted = next(client._resolved_auth.auth_flow(httpx.Request("POST", server.url)))
assert emitted.headers["Authorization"] == "Bearer example_token"
assert client.auth_type == MCPAuth.bearer_token