From b1442d0398b0333890837bbf97b1d8bd6303d115 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Tue, 14 Jul 2026 20:21:26 -0700 Subject: [PATCH] refactor(e2e): unroll the auth-header matrix into explicitly named per-header tests --- tests/e2e/mcp/mcp_client.py | 7 +- tests/e2e/mcp/test_mcp_tool_access_e2e.py | 120 ++++++++++++---------- 2 files changed, 69 insertions(+), 58 deletions(-) diff --git a/tests/e2e/mcp/mcp_client.py b/tests/e2e/mcp/mcp_client.py index 46ae99a3d64..a6ac16fb3e9 100644 --- a/tests/e2e/mcp/mcp_client.py +++ b/tests/e2e/mcp/mcp_client.py @@ -10,8 +10,7 @@ Every protocol method takes the request headers as a plain dict, built inside the test body, so the exact wire format is visible where it is asserted. The gateway accepts the LiteLLM virtual key as either `x-litellm-api-key: Bearer sk-...` or `Authorization: Bearer sk-...` (both -Bearer-prefixed on the MCP routes, matching the docs); `McpHeaderName` names -those two documented header styles for the tests' parametrized matrices. +Bearer-prefixed on the MCP routes, matching the docs). """ from __future__ import annotations @@ -19,7 +18,7 @@ from __future__ import annotations import asyncio import time from dataclasses import dataclass -from typing import Literal, Mapping, cast +from typing import Mapping, cast import httpx import pytest @@ -33,8 +32,6 @@ from e2e_gateway import Gateway, build_gateway from e2e_http import NoBody, unwrap from models import McpServerCreateBody, McpServerInfo -McpHeaderName = Literal["x-litellm-api-key", "Authorization"] - ToolArguments = Mapping[str, str | float] diff --git a/tests/e2e/mcp/test_mcp_tool_access_e2e.py b/tests/e2e/mcp/test_mcp_tool_access_e2e.py index 9a1090e8bdf..2602425d218 100644 --- a/tests/e2e/mcp/test_mcp_tool_access_e2e.py +++ b/tests/e2e/mcp/test_mcp_tool_access_e2e.py @@ -8,21 +8,21 @@ all against the deterministic mcp-stub compose service (tests/e2e/mcp/stub/). Each test body spells out every step a human QA run would take, in order: create the server over the management API, read the record back, mint a -virtual key over /key/generate, build the exact wire header -(`
: Bearer sk-...`, the documented contract on the MCP routes; a bare -key in `x-litellm-api-key` is accepted on LLM routes but 401s here), then -drive the real MCP protocol through the gateway the way a production MCP host -does (initialize, tools/list, tools/call over streamable HTTP at +virtual key over /key/generate, build the exact wire header, then drive the +real MCP protocol through the gateway the way a production MCP host does +(initialize, tools/list, tools/call over streamable HTTP at {PROXY}/{alias}/mcp) and assert the enforced behavior. Teardown is the only thing delegated (resources.defer), because it must run even when an assertion fails. -The two header styles are one behavior matrix, not two behaviors: the same -test body runs once per header via parametrize, so the specs cannot drift -apart, and each parametrization claims its own registry cells through -param-level `covers` marks. The unknown-key rejection is its own test (also -run per header) and settles the server with a real key first, so its 401 can -only be the gateway's ingress auth refusing the key, never record propagation. +The two documented header styles are deliberately separate, explicitly named +tests rather than one parametrized body, so the literal header each one sends +(`x-litellm-api-key: Bearer sk-...` / `Authorization: Bearer sk-...`) is +visible in its own body. Both are Bearer-prefixed on the MCP routes, matching +the docs; a bare key in `x-litellm-api-key` is accepted on LLM routes but +401s here. The unknown-key rejection tests settle the server with a real key +first, so their 401 can only be the gateway's ingress auth refusing the key, +never record propagation. """ from __future__ import annotations @@ -31,54 +31,23 @@ import pytest from e2e_config import MCP_STUB_URL, unique_marker from lifecycle import ResourceManager -from mcp_client import McpClient, McpDenied, McpHeaderName +from mcp_client import McpClient, McpDenied from models import KeyGenerateBody, McpServerCreateBody pytestmark = pytest.mark.e2e STUB_TOOLS = ("echo", "slow_echo", "stats") -AUTH_HEADER_MATRIX = ( - pytest.param( - "x-litellm-api-key", - id="x-litellm-api-key", - marks=( - pytest.mark.covers("mcp.list_tools.api_key.succeeds"), - pytest.mark.covers("mcp.call_tool.api_key.succeeds"), - ), - ), - pytest.param( - "Authorization", - id="authorization-bearer", - marks=( - pytest.mark.covers("mcp.list_tools.bearer.succeeds"), - pytest.mark.covers("mcp.call_tool.bearer.succeeds"), - ), - ), -) - -REJECTION_MATRIX = ( - pytest.param( - "x-litellm-api-key", - id="x-litellm-api-key", - marks=pytest.mark.covers("mcp.list_tools.api_key.rejects_unknown_key"), - ), - pytest.param( - "Authorization", - id="authorization-bearer", - marks=pytest.mark.covers("mcp.list_tools.bearer.rejects_unknown_key"), - ), -) - class TestMcpToolAccess: """Any virtual key reaches an allow_all_keys server through either documented auth header; a key the proxy does not recognize is turned away at the door.""" - @pytest.mark.parametrize("header_name", AUTH_HEADER_MATRIX) - def test_list_and_call_tools( - self, header_name: McpHeaderName, client: McpClient, resources: ResourceManager + @pytest.mark.covers("mcp.list_tools.api_key.succeeds") + @pytest.mark.covers("mcp.call_tool.api_key.succeeds") + def test_list_and_call_tools_with_x_litellm_api_key_header( + self, client: McpClient, resources: ResourceManager ) -> None: alias = f"e2emcp{unique_marker()}" created = client.create_server(McpServerCreateBody(alias=alias, url=MCP_STUB_URL, allow_all_keys=True)) @@ -92,7 +61,7 @@ class TestMcpToolAccess: key = client.gateway.generate_key(KeyGenerateBody()) resources.defer(lambda: client.gateway.delete_key(key)) - headers = {header_name: f"Bearer {key}"} + headers = {"x-litellm-api-key": f"Bearer {key}"} names = client.poll_tool_names(alias, headers) expected = tuple(sorted(f"{alias}-{tool}" for tool in STUB_TOOLS)) assert names == expected, f"gateway listed {names}, expected exactly {expected}" @@ -102,9 +71,36 @@ class TestMcpToolAccess: assert result.is_error is False, f"echo call errored: {result.text[:300]}" assert result.text == payload - @pytest.mark.parametrize("header_name", REJECTION_MATRIX) - def test_unrecognized_key_is_turned_away( - self, header_name: McpHeaderName, client: McpClient, resources: ResourceManager + @pytest.mark.covers("mcp.list_tools.bearer.succeeds") + @pytest.mark.covers("mcp.call_tool.bearer.succeeds") + def test_list_and_call_tools_with_authorization_bearer_header( + self, client: McpClient, resources: ResourceManager + ) -> None: + alias = f"e2emcp{unique_marker()}" + created = client.create_server(McpServerCreateBody(alias=alias, url=MCP_STUB_URL, allow_all_keys=True)) + resources.defer(lambda: client.delete_server(created.server_id)) + + stored = client.server_info(created.server_id) + assert stored.alias == alias + assert stored.url == MCP_STUB_URL + assert stored.allow_all_keys is True + + key = client.gateway.generate_key(KeyGenerateBody()) + resources.defer(lambda: client.gateway.delete_key(key)) + + headers = {"Authorization": f"Bearer {key}"} + names = client.poll_tool_names(alias, headers) + expected = tuple(sorted(f"{alias}-{tool}" for tool in STUB_TOOLS)) + assert names == expected, f"gateway listed {names}, expected exactly {expected}" + + payload = f"e2e-{unique_marker()}" + result = client.call_tool(alias, headers, f"{alias}-echo", {"text": payload}) + assert result.is_error is False, f"echo call errored: {result.text[:300]}" + assert result.text == payload + + @pytest.mark.covers("mcp.list_tools.api_key.rejects_unknown_key") + def test_unrecognized_key_in_x_litellm_api_key_header_is_turned_away( + self, client: McpClient, resources: ResourceManager ) -> None: """Settle the server with a real key first, then present an unknown key over the same header: the refusal must be an explicit 401 from @@ -116,8 +112,26 @@ class TestMcpToolAccess: key = client.gateway.generate_key(KeyGenerateBody()) resources.defer(lambda: client.gateway.delete_key(key)) - _ = client.poll_tool_names(alias, {header_name: f"Bearer {key}"}) + _ = client.poll_tool_names(alias, {"x-litellm-api-key": f"Bearer {key}"}) - denied = client.list_tools_once(alias, {header_name: "Bearer sk-not-a-real-key"}) + denied = client.list_tools_once(alias, {"x-litellm-api-key": "Bearer sk-not-a-real-key"}) + assert isinstance(denied, McpDenied), f"unrecognized key was served tools: {denied}" + assert denied.status_code == 401, f"expected 401 for an unrecognized key, got {denied}" + + @pytest.mark.covers("mcp.list_tools.bearer.rejects_unknown_key") + def test_unrecognized_key_in_authorization_bearer_header_is_turned_away( + self, client: McpClient, resources: ResourceManager + ) -> None: + """The Authorization form of the same rejection: an unknown key sent + as `Authorization: Bearer ...` must be refused identically.""" + alias = f"e2emcp{unique_marker()}" + created = client.create_server(McpServerCreateBody(alias=alias, url=MCP_STUB_URL, allow_all_keys=True)) + resources.defer(lambda: client.delete_server(created.server_id)) + + key = client.gateway.generate_key(KeyGenerateBody()) + resources.defer(lambda: client.gateway.delete_key(key)) + _ = client.poll_tool_names(alias, {"Authorization": f"Bearer {key}"}) + + denied = client.list_tools_once(alias, {"Authorization": "Bearer sk-not-a-real-key"}) assert isinstance(denied, McpDenied), f"unrecognized key was served tools: {denied}" assert denied.status_code == 401, f"expected 401 for an unrecognized key, got {denied}"