Skip to content

test(e2e): cover MCP gateway auth headers and per-server max concurrency - #33102

Open
tin-berri wants to merge 17 commits into
litellm_internal_stagingfrom
litellm_mcp_e2e_tests
Open

test(e2e): cover MCP gateway auth headers and per-server max concurrency#33102
tin-berri wants to merge 17 commits into
litellm_internal_stagingfrom
litellm_mcp_e2e_tests

Conversation

@tin-berri

@tin-berri tin-berri commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Linear ticket

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • 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 received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Screenshots / Proof of Fix

Everything below was first captured on 2026-07-13 with the test code at commit dd05a44 and re-executed after each follow-up (the suite run and static gates most recently at head 0f8db78, the negative control at daf31ca; the later commits are readability refactors of the concurrency test with identical assertions), against proxies built from source at exact refs, over the deterministic mcp-stub upstream this PR adds to the compose stack. The suite drives the real MCP protocol through the gateway (initialize, tools/list, tools/call over streamable HTTP with the official mcp SDK); no LLM calls are involved in these flows. Outputs are pasted verbatim except that the pre-existing "spend-log cleanup best-effort failed" session-finish notice (it fires on any local e2e run without a host Postgres at localhost:5432 and does not affect results) is elided where it interleaves with pytest lines. Following the same steps reproduces everything end to end

  1. Build a proxy image at the exact base ref this branch sits on:
git -C /path/to/litellm worktree add --detach /tmp/wt-staging 3d400b5be9
cd /tmp/wt-staging && docker build -t litellm-local:stg-3d400b5be9 .
  1. Point the tests/e2e compose stack at that image on a free host port (my dev proxy owns 4000) via an override file:
services:
  litellm:
    image: litellm-local:stg-3d400b5be9
    ports: !override
      - "4610:4000"
  1. tests/e2e/.env carries OPENAI_API_KEY, ANTHROPIC_API_KEY, LITELLM_LICENSE for stack startup parity with the other suites; the mcp suite itself never calls a provider

  2. Bring the stack up (this builds and starts the new mcp-stub service alongside the proxy) and confirm liveness:

cd tests/e2e
docker compose -p mcpe2e -f docker-compose.yml -f /tmp/override-mcpe2e.yml up -d
curl -fs http://localhost:4610/health/liveliness
"I'm alive!"
  1. Run the suite (mcp SDK comes from the e2e-dev group: uv sync --inexact --group e2e-dev); output below from the 0f8db78 re-run:
LITELLM_PROXY_URL=http://localhost:4610 uv run --no-sync pytest tests/e2e/mcp/ -v

tests/e2e/mcp/test_mcp_gateway_e2e.py::TestMcpToolAccess::test_list_and_call_tools_with_x_litellm_api_key_header PASSED [ 33%]
tests/e2e/mcp/test_mcp_gateway_e2e.py::TestMcpToolAccess::test_list_and_call_tools_with_authorization_bearer_header PASSED [ 66%]
tests/e2e/mcp/test_mcp_gateway_e2e.py::TestMcpServerMaxConcurrency::test_max_concurrent_requests_caps_in_flight_upstream_calls PASSED [100%]
============================== 3 passed in 9.36s ===============================
  1. Negative control, so the suite is shown to fail on a proxy whose enforcement genuinely lacks the feature rather than rubber-stamping whatever it hits. The per-server semaphore landed in 58de920 (feat(mcp): bound outbound tool-call concurrency per MCP server #31641); its parent 8e6098a predates the field entirely. Build that ref, run the identical suite:
git worktree add --detach /tmp/wt-negctl 8e6098adc3
cd /tmp/wt-negctl && docker build -t litellm-local:pre-31641 .
docker compose -p negctl -f docker-compose.yml -f /tmp/override-negctl.yml up -d   # port 4611
LITELLM_PROXY_URL=http://localhost:4611 uv run --no-sync pytest tests/e2e/mcp/ -v

tests/e2e/mcp/test_mcp_gateway_e2e.py::TestMcpToolAccess::test_list_and_call_tools_with_x_litellm_api_key_header PASSED [ 33%]
tests/e2e/mcp/test_mcp_gateway_e2e.py::TestMcpToolAccess::test_list_and_call_tools_with_authorization_bearer_header PASSED [ 66%]
tests/e2e/mcp/test_mcp_gateway_e2e.py::TestMcpServerMaxConcurrency::test_max_concurrent_requests_caps_in_flight_upstream_calls FAILED [100%]

>       assert client.server_info(capped.server_id).max_concurrent_requests == MAX_CONCURRENT
E       AssertionError: assert None == 2
E        +  where None = McpServerInfo(server_id='2e5dfb96-3e78-40a9-bcab-3dfd7a34a6c1', alias='e2emcpcapf5b07eb8dc37', url='http://mcp-stub:8765/mcp', transport='http', allow_all_keys=True, max_concurrent_requests=None).max_concurrent_requests

tests/e2e/mcp/test_mcp_gateway_e2e.py:137: AssertionError
========================= 1 failed, 2 passed ==========================

The auth tests pass on the old ref (that behavior predates it) and the concurrency test fails at its recorded-state assertion because that proxy neither stores nor enforces the cap; the enforcement half is falsified continuously by the in-test uncapped control server, which must observe all 6 calls overlapping through the identical burst machinery every run (a broken or skipped semaphore makes the capped server record 6 exactly like the control, failing the equality assertion)

  1. The auth-header contract itself, shown raw so a reviewer can see what the tests encode. Bare keys in x-litellm-api-key are accepted on LLM routes but the MCP mount only accepts the Bearer-prefixed form both docs document:
POST /probe1/mcp (initialize)
x-litellm-api-key: sk-1234              => 401
x-litellm-api-key: Bearer <virtual key> => 200
Authorization: Bearer <virtual key>     => 200
GET /v1/models
x-litellm-api-key: sk-1234              => 200

The tests encode the documented Bearer-prefixed contract; the bare-key inconsistency between the LLM and MCP surfaces is flagged here as a product finding for a separate decision rather than worked around

  1. Teardown both stacks: docker compose -p <project> -f docker-compose.yml -f <override> down -v

  2. Static gates re-run at 0f8db78: uv run --no-sync basedpyright tests/e2e reports 0 errors, and python -m coverage_registry.collector --strict (a full collect-only pass resolving every covers marker against the registry) reports the MCPs module moving from 0/14 on the base branch to 5/15 here, with the caps_concurrency row added by this PR

  3. Restructure at f503c3a (the branch is also rebased onto current litellm_internal_staging as of this commit; the uv.lock conflict was resolved by re-locking on top of staging's lock with the exclude-newer snapshot anchor preserved, and the proxy's compose depends_on now gates on both the jaeger and mcp-stub services): the single test_mcp_gateway_e2e.py is split into feature-scoped specs with identical assertions. The two near-duplicate header tests become one parametrized matrix (each parametrization claims its registry cells via param-level covers marks, so the Authorization arm now also runs the allow_all_keys round-trip it previously skipped), and the unrecognized-key rejection becomes its own test run under both headers (previously it only ran under x-litellm-api-key), claiming two new rejects_unknown_key registry rows. Re-run against the compose stack after the split, plus both static gates green at this head:

LITELLM_PROXY_URL=http://localhost:4000 uv run --no-sync pytest tests/e2e/mcp/ -v

tests/e2e/mcp/test_mcp_concurrency_e2e.py::TestMcpServerMaxConcurrency::test_max_concurrent_requests_caps_in_flight_upstream_calls PASSED [ 20%]
tests/e2e/mcp/test_mcp_tool_access_e2e.py::TestMcpToolAccess::test_list_and_call_tools[x-litellm-api-key] PASSED [ 40%]
tests/e2e/mcp/test_mcp_tool_access_e2e.py::TestMcpToolAccess::test_list_and_call_tools[authorization-bearer] PASSED [ 60%]
tests/e2e/mcp/test_mcp_tool_access_e2e.py::TestMcpToolAccess::test_unrecognized_key_is_turned_away[x-litellm-api-key] PASSED [ 80%]
tests/e2e/mcp/test_mcp_tool_access_e2e.py::TestMcpToolAccess::test_unrecognized_key_is_turned_away[authorization-bearer] PASSED [100%]
============================== 5 passed in 9.50s ===============================
  1. Readability pass at e5b8967, so each test body reads 1:1 against its QA runbook entry with nothing hidden behind fixtures: tests mint their own virtual key inline (client.gateway.generate_key + a deferred delete) instead of the scoped_key fixture, and build the exact wire header dict inline ({"x-litellm-api-key": f"Bearer {key}"}) instead of the McpAuth wrapper, which is deleted; the suite client's protocol methods now take a plain headers dict. Identical assertions, re-run green (5 passed) with both static gates clean

  2. Explicitness pass at 6d3d1d7: the parametrized header matrix from item 10 is unrolled again into four explicitly named tests (test_list_and_call_tools_with_x_litellm_api_key_header, test_list_and_call_tools_with_authorization_bearer_header, and the two per-header test_unrecognized_key_* rejections), so the literal header dict each test sends sits in its own body instead of behind a parametrize id; covers marks moved back onto each test. Identical assertions; re-run green (5 passed in 9.37s) with both static gates clean at this head

  3. Interactive OAuth coverage at 0ba1fb6: the suite gains test_mcp_oauth_interactive_e2e.py, which drives the full authorization_code flow end to end against a gateway-managed oauth2 server. The stub grows into a deterministic OAuth2 IdP (an auto-approving /oauth/authorize, a token endpoint speaking the authorization_code grant with one-time codes and S256 PKCE verification plus refresh_token, and a /oauthuser/mcp mount that rejects anything but the per-user token that grant hands out; the guard records the headers of the last authorized request, read back through a recorded_headers tool). The MCP-host side is the official mcp SDK's own OAuth machinery (OAuthClientProvider: RFC 9728/8414 discovery against the gateway, RFC 7591 dynamic client registration, PKCE, token exchange), with the browser leg replaced by a redirect chaser following the authorize chain (gateway -> stub IdP -> gateway callback -> host redirect_uri) with plain GETs; the caller's LiteLLM key is injected at the httpx transport layer because the SDK's internally-built OAuth requests bypass client-default headers, and the gateway needs the key on the token exchange to know which user to store the upstream token for. The key must belong to a user: a user-less service-account key completes the client-facing dance, but the gateway logs that it dropped the token and subsequent sessions serve an empty tool list instead of a re-challenge (flagged as a separate product finding). The compose file publishes the stub's port to the host because the browser leg runs from pytest, while token exchange stays proxy-side on the compose network. Two P0 registry rows are added (mcp.list_tools.oauth.completes_authorization_code_flow, mcp.call_tool.oauth.uses_per_user_token); the suite is 6 passed in ~10s at this head with both static gates clean

  4. Foundation pass at 68538f3, adopting how the wider MCP ecosystem tests servers without growing the test surface: the stub gains a /conformance mount implementing the official @modelcontextprotocol/conformance suite's hardcoded fixture contract across all three primitives (test_simple_text / test_error_handling tools, the test://static-text, test://static-binary and test://template/{id}/data resources, the test_simple_prompt / test_prompt_with_arguments prompts), auth_forwarder.py provides an in-process relay that stamps the virtual key onto every request (the official CLI and similar host tools cannot send custom headers), and the suite client speaks prompts/list, prompts/get, resources/list and resources/read. The suite itself stays scoped to the core happy paths (6 tests); running the official suite or covering the prompt/resource registry cells is one small test file away on this foundation, e.g. npx -y @modelcontextprotocol/conformance@0.1.11 server --url http://127.0.0.1://mcp --scenario

A manual sweep of 14 curated official scenarios over this foundation (9 passed) surfaced four real gateway gaps, verified by hand and documented here rather than encoded as tests for now: logging/setLevel and completion/complete both answer -32601 Method not found instead of being relayed upstream; prompts/get resolves alias-prefixed names only, so the unprefixed names a spec-reading host may send are 403'd (tools/call has an unprefixed fallback, prompts/get does not); and binary resource reads drop the base64 blob field on the way through (the stub demonstrably serves it; the gateway's answer has uri and mimeType but no blob). The namespacing contract observed live: prompt names are alias-prefixed like tool names, resource URIs pass through unprefixed. A fifth gap, reproduced at 3b82532 and now pinned as a deliberately failing test: a virtual key in the Authorization header of an MCP session on an oauth2 server skips the pre-session 401 challenge entirely and the session is served a masked, empty tool list, so an OAuth-capable host that engages on 401 never starts the dance

  1. Happy-path relocation at a03750f: the list-and-call tests move out of test_mcp_tool_access_e2e.py (which now holds only the unknown-key ingress gate) into test_mcp_oauth_interactive_e2e.py, so the core happy path runs against a real OAuth server with a scoped internal-user key (object_permission grant, allow_all_keys false) under both documented headers. The x-litellm-api-key form passes end to end. The Authorization form is checked in deliberately failing: it asserts the contract (same 401 challenge, same dance, same tools) and currently fails at the challenge step on the finding-five gap above, making it the regression guard for that fix. Suite at this head: 4 passed, 1 intentional failure, both static gates clean

  2. Rebased onto current litellm_internal_staging at 9b144f1 (128 commits, including the new e2e integration line and the datadog logging suite). Conflicts were confined to the shared harness surfaces where both lines added entries: e2e_config.py (the dd-sink query URL alongside the mcp-stub URLs) and the compose depends_on (now gating on dd-sink and mcp-stub), resolved as unions; uv.lock was re-resolved on staging's own exclude-newer anchor and differs from staging by exactly the two mcp e2e-dev entries. Suite re-run at this head: 4 passed plus the one intentional failure, collector --strict and basedpyright clean, and GitHub reports the PR mergeable again

Type

✅ Test

Changes

Adds tests/e2e/mcp/, the first live coverage of the MCPs registry module, claiming mcp.list_tools.api_key.succeeds, mcp.call_tool.api_key.succeeds, mcp.list_tools.bearer.succeeds, mcp.call_tool.bearer.succeeds, the new mcp.call_tool.api_key.caps_concurrency cell, and the new mcp.list_tools.{api_key,bearer}.rejects_unknown_key cells

The suite is laid out as one file per feature, and every test body spells out each QA step inline: register the server over POST /v1/mcp/server, assert the record round-trips through GET /v1/mcp/server/{id}, mint a virtual key over /key/generate, build the literal wire header dict, then drive initialize, tools/list, and tools/call through the gateway's per-server URL namespace {PROXY}/{alias}/mcp exactly like a production MCP host, asserting the exact tool listing and an exact echo round-trip. The two documented auth headers are deliberately two explicitly named twin tests (test_list_and_call_tools_with_x_litellm_api_key_header builds {"x-litellm-api-key": f"Bearer {key}"}, test_list_and_call_tools_with_authorization_bearer_header builds {"Authorization": f"Bearer {key}"}) so the header each sends is visible in its own body. The unrecognized-key rejection is likewise its own pair of per-header tests; each settles the server with a real key first, so the 401 it asserts can only be the ingress auth refusing the key rather than record propagation

test_mcp_concurrency_e2e.py holds the cap spec, unchanged in behavior from earlier revisions of this PR

The concurrency test registers a capped server (max_concurrent_requests 2) and an uncapped control against the same upstream, asserts the cap round-trips (the control echoes null), fires 6 simultaneous slow tool calls at each, and reads the upstream's own per-marker in-flight counters back through the proxy via the stub's stats tool. The proxy-side semaphore queues rather than rejects, so the contract asserted is that all 6 calls succeed while the stub observes exactly 2 overlapping calls on the capped server and exactly 6 on the control

Known failure against current main, on purpose: test_key_in_authorization_header_is_challenged_not_masked pins that a virtual key presented as Authorization: Bearer on a gateway-managed oauth2 server must get the 401 OAuth challenge, not a masked 200+empty tool list. Today any bearer in Authorization skips the pre-session challenge and the session opens masked, so this test fails at the challenge assertion. It is a challenge-guard, not a full round-trip: completing the dance via Authorization is unreachable because the SDK carries the minted OAuth token in Authorization after the dance, evicting the LiteLLM key the gateway needs to resolve the per-user token, so the full list-and-call lives only on the x-litellm-api-key variant. This flips green when the mode-aware gateway challenge fix (PR #33586) reaches main-latest; verified live against a patched gateway

The conformance-ready foundation from proof item 14 (the stub's fixture-complete /conformance mount, auth_forwarder.py, and the client's prompt/resource operations) ships without tests of its own, keeping the suite scoped to the core happy paths

test_mcp_oauth_interactive_e2e.py holds the interactive OAuth spec described in proof item 13: the 401 challenge contract, the full authorization_code dance run by the mcp SDK's own OAuth client, and the per-user-token egress assertions via the stub's recorded_headers read-back

Supporting changes: a deterministic stub MCP upstream (tests/e2e/mcp/stub/, FastMCP from the official mcp SDK pinned to the version litellm locks) added to the compose stack as the mcp-stub service, reachable by the proxy at http://mcp-stub:8765/mcp and overridable via E2E_MCP_STUB_URL for deployments where the compose stub is not visible; McpServerCreateBody/McpServerInfo in models.py; the suite client in mcp_client.py routing management calls through the shared Gateway transport and protocol calls through the mcp SDK (added to the e2e-dev dependency group behind the same importorskip pattern as playwright); the caps_concurrency registry row plus grammar note; and the suite line in tests/e2e/CLAUDE.md

QA runbook

Prerequisites: the tests/e2e compose stack (which now includes the mcp-stub service), no provider keys needed for this suite, and the mcp SDK (uv sync --inexact --group e2e-dev). Every MCP-route request below carries the key Bearer-prefixed in whichever header the step names; an MCP client here means any streamable-http MCP client, e.g. the mcp python SDK, pointed at the per-server URL http://localhost:4000/<alias>/mcp

  • tests/e2e/mcp/test_mcp_tool_access_e2e.py::TestMcpToolAccess::test_unrecognized_key_in_x_litellm_api_key_header_is_turned_away - a key the proxy does not recognize is refused with 401 at session establishment
    • POST /v1/mcp/server with the master key and {"alias": "e2emcp", "url": "http://mcp-stub:8765/mcp", "transport": "http", "allow_all_keys": true}; expect 201
    • GET /v1/mcp/server/{server_id} with the master key; expect alias and url to echo and allow_all_keys true
    • POST /key/generate with the master key and {}; note the returned sk- key
    • Connect an MCP client to http://localhost:4000//mcp with header "x-litellm-api-key: Bearer "; initialize then tools/list to a deadline until the tools appear, proving the server is servable
    • Repeat tools/list with "x-litellm-api-key: Bearer sk-not-a-real-key"; expect HTTP 401
    • DELETE /v1/mcp/server/{server_id} and POST /key/delete to clean up
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky
  • tests/e2e/mcp/test_mcp_tool_access_e2e.py::TestMcpToolAccess::test_unrecognized_key_in_authorization_bearer_header_is_turned_away - an unknown key presented as Authorization: Bearer is refused identically
    • POST /v1/mcp/server with the master key and {"alias": "e2emcp", "url": "http://mcp-stub:8765/mcp", "transport": "http", "allow_all_keys": true}; expect 201
    • GET /v1/mcp/server/{server_id} with the master key; expect alias and url to echo and allow_all_keys true
    • POST /key/generate with the master key and {}; note the returned sk- key
    • Connect an MCP client to http://localhost:4000//mcp with header "Authorization: Bearer "; initialize then tools/list to a deadline until the tools appear, proving the server is servable
    • Repeat tools/list with "Authorization: Bearer sk-not-a-real-key"; expect HTTP 401
    • DELETE /v1/mcp/server/{server_id} and POST /key/delete to clean up
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky
  • tests/e2e/mcp/test_mcp_oauth_interactive_e2e.py::TestMcpOauthAuthorizationCode::test_list_and_call_tools_with_x_litellm_api_key_header - a scoped internal-user key presented in x-litellm-api-key is 401-challenged, completes the interactive dance, and lists and calls tools with the per-user upstream token
    • POST /v1/mcp/server with the master key: auth_type oauth2, oauth2_flow authorization_code, allow_all_keys false, url set to the /oauthuser stub mount, authorization_url set to the stub IdP's host-visible authorize endpoint, token_url set to its proxy-visible token endpoint, and the stub's user-app client_id/client_secret in credentials; expect 201
    • GET /v1/mcp/server/{server_id}; expect auth_type, oauth2_flow, authorization_url, and token_url to echo with credentials null
    • POST /key/generate with the master key, {"user_id": "e2e-test-user"} and object_permission granting exactly this server (the key is a real scoped internal user; a user-less key cannot have a per-user token stored for it)
    • POST /v1/mcp/server for a second, anonymous-stub server and tools/list it with the key to a deadline, proving the key and records propagated
    • Connect an MCP client to the oauth server's /mcp URL with only "x-litellm-api-key: Bearer "; expect the session refused with 401 (the OAuth challenge)
    • Connect with an OAuth-capable MCP host (e.g. the mcp SDK client) carrying the key header on every gateway request: let it discover the gateway's AS metadata, dynamically register, open the authorize URL, follow the redirects through the stub IdP back to the gateway callback and on to the host's redirect_uri, and exchange the code
    • tools/list through the authorized session; expect exactly -echo and -recorded_headers
    • tools/call -echo with a unique string; expect the exact string back with isError false
    • tools/call -recorded_headers; expect the upstream to have received authorization "Bearer <the stub's per-user access token>" and your virtual key value in no header
    • DELETE both /v1/mcp/server records and POST /key/delete to clean up
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky
  • tests/e2e/mcp/test_mcp_oauth_interactive_e2e.py::TestMcpOauthAuthorizationCode::test_key_in_authorization_header_is_challenged_not_masked - a key in Authorization on a gateway-managed oauth2 server is challenged (not masked); FAILS on current main by design (see the known-failure note under Changes)
  • tests/e2e/mcp/test_mcp_oauth_interactive_e2e.py::TestMcpOauthAuthorizationCode::test_stored_token_serves_list_and_call_with_key_in_authorization_header - once a per-user token is stored, a plain Authorization: Bearer key session lists and calls tools using the stored token; passes on current main
    • Register a gateway-managed oauth2 authorization_code server (the /oauthuser stub mount) with allow_all_keys false, and mint a user-bound key scoped to it
    • Seed the stored per-user token by completing the interactive dance once with the key in x-litellm-api-key; expect the two guarded tools listed
    • Open a fresh plain session (no OAuth client) with only Authorization: Bearer key; tools/list expect exactly the two guarded tools, tools/call echo expect the exact round-trip
    • tools/call recorded_headers; expect the upstream to have received the stored per-user token (not the LiteLLM key) and your key in no header
    • DELETE the server and POST /key/delete to clean up
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky
    • Register the same authorization_code server shape and scoped user-bound key as the previous entry
    • Connect an MCP client with only "Authorization: Bearer "; the contract says this session must receive the same 401 OAuth challenge as the x-litellm-api-key form
    • Current gateways instead serve a masked, empty tool list (any bearer in Authorization skips the pre-session challenge), so an OAuth-capable host never starts the dance; this test fails at the challenge assertion and is the regression guard for that fix
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky
  • tests/e2e/mcp/test_mcp_concurrency_e2e.py::TestMcpServerMaxConcurrency::test_max_concurrent_requests_caps_in_flight_upstream_calls - a server capped at 2 concurrent requests never sees more than 2 overlapping tool calls out of a burst of 6, all 6 still succeed by queueing, and an uncapped control sees all 6 overlap
    • POST /v1/mcp/server with the master key, {"alias": "e2emcpcap", "url": "http://mcp-stub:8765/mcp", "transport": "http", "allow_all_keys": true, "max_concurrent_requests": 2}; expect 201
    • POST /v1/mcp/server again without max_concurrent_requests as the control, alias "e2emcpfree"; expect 201
    • GET each /v1/mcp/server/{server_id}; expect the capped one to echo max_concurrent_requests 2 and the control null
    • POST /key/generate with the master key; connect with "x-litellm-api-key: Bearer " and tools/list both aliases to a deadline so both are servable
    • Open 6 simultaneous MCP sessions to the capped alias and tools/call -slow_echo with {"text": "capped", "marker": "", "sleep_seconds": 2} in all 6 at once; expect every call to return "capped" with isError false
    • tools/call -stats with {"marker": ""}; expect exactly {"max_in_flight": 2, "completed": 6}
    • Repeat the 6-call burst against the control alias with a fresh marker ; expect every call to succeed and -stats for to read exactly {"max_in_flight": 6, "completed": 6}
    • DELETE both /v1/mcp/server/{server_id} records and POST /key/delete to clean up
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

@tin-berri
tin-berri requested a review from a team July 13, 2026 18:13
@greptile-apps

greptile-apps Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces the first live e2e coverage of the MCP gateway, adding tests/e2e/mcp/ with a deterministic stub upstream, a test client, and three tests that drive the full MCP protocol (initialize → tools/list → tools/call over streamable HTTP) through the proxy.

  • Auth header tests register an allow_all_keys server, poll until the key and server record propagate, then assert exact tool listing and echo round-trips for both x-litellm-api-key: Bearer and Authorization: Bearer; the first variant also asserts a 401 for an unrecognized key.
  • Concurrency test registers a capped (max_concurrent_requests=2) and an uncapped control server, fires a burst of 6 simultaneous slow_echo calls at each, and reads the stub's own per-marker in-flight counters back through the proxy to assert the semaphore holds the capped server at exactly 2 while the control passes all 6.
  • Supporting additions: mcp-stub compose service with a healthcheck and a depends_on from litellm, McpServerCreateBody/McpServerInfo Pydantic models, MCP_STUB_URL config override, and the caps_concurrency assertion variant in the coverage registry and CLAUDE.md naming convention.

Confidence Score: 5/5

Safe to merge; changes are entirely within the e2e test infrastructure and do not touch any production code paths.

All changes are additive test scaffolding — a new compose service, a stub server, a test client, and three tests. No production code is modified. The previously flagged issues have all been addressed in follow-up commits.

The control-server max_in_flight == burst_size assertion in tests/e2e/mcp/test_mcp_gateway_e2e.py has a timing dependency worth reviewing.

Important Files Changed

Filename Overview
tests/e2e/mcp/test_mcp_gateway_e2e.py New e2e suite covering MCP auth headers and per-server concurrency cap; the control server's strict max_in_flight == burst_size assertion is timing-sensitive and may be intermittently fragile on slow CI.
tests/e2e/mcp/mcp_client.py New MCP test client routing management calls via Gateway transport and MCP protocol calls via the official mcp SDK; asyncio.run() usage in ThreadPoolExecutor threads is safe since each thread gets its own loop.
tests/e2e/mcp/stub/stub_server.py Deterministic FastMCP upstream with echo, slow_echo (per-marker in-flight tracking), and stats tools; counter atomicity relies correctly on asyncio single-threaded scheduling with no await between increments.
tests/e2e/docker-compose.yml Adds mcp-stub service with healthcheck and litellm depends_on, addressing the previously flagged startup ordering issue.
tests/e2e/models.py Adds McpServerCreateBody and McpServerInfo Pydantic models; all optional fields correctly default to None for the response shape.
tests/e2e/mcp/stub/Dockerfile Minimal Python 3.13-slim image pinning mcp==1.26.0; intentionally independent of the e2e-dev client range per team decision documented in prior review thread.
tests/e2e/mcp/conftest.py pytest.importorskip guard correctly prevents conftest loading (and downstream mcp imports) when the mcp SDK is absent, matching the playwright pattern.

Reviews (5): Last reviewed commit: "refactor(e2e): keep the concurrency test..." | Re-trigger Greptile

Comment thread tests/e2e/mcp/stub/Dockerfile
Comment thread tests/e2e/docker-compose.yml
Comment thread uv.lock Outdated
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@codspeed-hq

codspeed-hq Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_mcp_e2e_tests (644d1ec) with litellm_internal_staging (68f0fb0)

Open in CodSpeed

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@codecov

codecov Bot commented Jul 13, 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 tests/e2e/mcp/mcp_client.py Outdated
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

1 similar comment
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@tin-berri
tin-berri force-pushed the litellm_mcp_e2e_tests branch from a03750f to 9b144f1 Compare July 16, 2026 19:51
…uard, not a full dance

The Authorization-header variant cannot complete the interactive round-trip:
once the host finishes the OAuth dance the SDK carries the minted upstream
token in Authorization, evicting the LiteLLM key the gateway needs to resolve
the per-user token. Assert the achievable, correct contract instead: a key in
Authorization on a gateway-managed oauth2 server gets the 401 challenge, not a
masked empty tool list. Full list-and-call stays on the x-litellm-api-key
variant. Red on stock main (masking); flips green with the mode-aware gateway
challenge fix.
… and call tools

Positive companion to the Authorization challenge-guard: once a user has
authorized (per-user token stored via the x-litellm-api-key dance), a plain
Authorization: Bearer <key> session needs no dance and the gateway serves
tools using the stored token. Seeds the token via the dance, then lists and
calls over an Authorization-key session, and asserts the upstream received the
stored per-user token (not the key) with no key leak. Claims the previously
unclaimed mcp.{list_tools,call_tool}.bearer.succeeds cells. Green on stock.
@@ -0,0 +1,310 @@
"""Deterministic MCP upstreams for the mcp e2e suite.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use an actual oauth mcp server please (maybe the ones we're having issues with?)

command: ["--config", "/app/config.yaml", "--port", "4000"]

# throwaway db
mcp-stub:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dont need

Comment thread tests/e2e/mcp/auth_forwarder.py Outdated

def _build_app(upstream: httpx.AsyncClient, litellm_key: str) -> Starlette:
async def forward(request: Request) -> StreamingResponse:
url = httpx.URL(path=request.url.path, query=request.url.query.encode())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no httpx, use the tests/e2e/claude.md

Comment on lines +78 to +108
def test_list_and_call_tools_with_x_litellm_api_key_header(
self, client: McpClient, resources: ResourceManager
) -> None:
marker = unique_marker()
alias = f"e2emcpauthcode{marker}"
created = client.create_server(
McpServerCreateBody(
alias=alias,
url=MCP_STUB_OAUTHUSER_URL,
allow_all_keys=False,
auth_type="oauth2",
oauth2_flow="authorization_code",
authorization_url=MCP_STUB_AUTHORIZE_BROWSER_URL,
token_url=MCP_STUB_TOKEN_URL,
credentials=McpServerCredentials(
client_id=MCP_STUB_OAUTH_USER_CLIENT_ID,
client_secret=MCP_STUB_OAUTH_USER_CLIENT_SECRET,
),
)
)
resources.defer(lambda: client.delete_server(created.server_id))

stored = client.server_info(created.server_id)
assert stored.auth_type == "oauth2"
assert stored.oauth2_flow == "authorization_code"
assert stored.authorization_url == MCP_STUB_AUTHORIZE_BROWSER_URL
assert stored.token_url == MCP_STUB_TOKEN_URL
assert stored.allow_all_keys is False
assert stored.credentials is None, f"client secret must be redacted on read-back, got {stored.credentials}"

key = client.gateway.generate_key(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is too big of a function, can you split this into:

  • def test_mcp_create_oauth_mcp()
  • def test_mcp_fetch_oauth_tools() # assert token was stored in db
  • def test_mcp_call_tools() ## check if the token can call tools
  • def test_mcp_use_with_key() # try making a completions call

see what i mean?

Removes the auth_forwarder key-stamping relay (unused; raw httpx), the stub's
/conformance mount and its fixtures, MCP_STUB_CONFORMANCE_URL, the unused
prompt/resource client ops, and the unclaimed passes_official_conformance
registry cell. None of it was exercised by a test; the suite stays scoped to
the core happy paths against the deterministic stub.
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