mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
* 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
78 lines
2.8 KiB
Python
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
|