Skip to content

test(e2e): cover MCP core happy paths: upstream auth, access control, allowed_tools, aggregate routing - #33263

Draft
tin-berri wants to merge 2 commits into
litellm_mcp_e2e_testsfrom
litellm_mcp_e2e_core_flows
Draft

test(e2e): cover MCP core happy paths: upstream auth, access control, allowed_tools, aggregate routing#33263
tin-berri wants to merge 2 commits into
litellm_mcp_e2e_testsfrom
litellm_mcp_e2e_core_flows

Conversation

@tin-berri

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

Copy link
Copy Markdown
Contributor

Relevant issues

Stacked on #33102 (the first tests/e2e/mcp suite); this PR extends that suite and should merge after it

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)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

All runs are against the tests/e2e compose stack (ghcr.io/berriai/litellm:main-latest + postgres + redis + the mcp-stub service from this PR)

All runs are against the tests/e2e compose stack (ghcr.io/berriai/litellm:main-latest + postgres + redis + the mcp-stub service). Suite run across the stacked suite: 8 passed / 1 failed; the failure is the base PR's deliberately red Authorization-header OAuth test (challenge-skip masking gap documented in #33102), not a test from this PR

While building the since-removed client_credentials coverage, this work also surfaced and live-verified a real gateway bug worth recording: _raise_preemptive_401_for_unauthenticated_servers in litellm/proxy/_experimental/mcp_server/server.py challenges every oauth2 server that received no per-request oauth header, and because the raise sits at the outer auth_type == oauth2 level, client_credentials (M2M) servers, for which the gateway is supposed to mint its own token from stored credentials, fall through into an interactive OAuth challenge and are unusable over the MCP protocol path. Verified live: an early continue for server.has_client_credentials at the top of that block makes the full M2M flow work, with the token minted from the configured token_url exactly once and cached per expires_in. Recorded here as a finding for a separate fix; this PR carries no M2M test by choice

Type

✅ Test

Changes

Adds the core MCP happy-path coverage on top of #33102: upstream credential injection (static api_key), server-level access control via object_permission grants, server-level allowed_tools governance, and the aggregate /mcp namespace with x-mcp-servers scoping. Each test asserts the recorded state round-trips through the management API (with secrets redacted) and that the behavior actually bites on live MCP protocol traffic, per the e2e standards

The mcp-stub grows three sibling mounts next to the anonymous and interactive-OAuth ones the base branch ships, all in the same container and port: /second/mcp serves a deliberately disjoint tool set so aggregate routing is provable; /apikey/mcp rejects any request whose X-API-Key is not exactly the stub's expected secret; /oauth/mcp (with a client_credentials grant on the stub IdP's token endpoint) stays as foundation for future M2M coverage but carries no test in this PR. The guarded mounts record the headers of the last authorized request and expose them through the base branch's recorded_headers tool pattern, so tests can assert exactly which credentials the gateway attached upstream and that the caller's virtual key never crossed the gateway boundary; a served call on the guarded mount is itself proof the gateway injected the credential, which is the fail-before-fix evidence built into the upstream-auth test

Harness changes are additive: models.py gains the allowed_tools field on the MCP server bodies and object_permission on key generation, mcp_client.py gains aggregate-namespace helpers and a call_tool_once that models denials as values, and coverage_registry/mcp.yaml gains five new cells (the two denial cells and both oauth cells were already in the registry and are now claimed by tests; the interactive authorization_code cells ship with the base branch)

QA runbook

  • tests/e2e/mcp/test_mcp_upstream_auth_e2e.py::TestMcpUpstreamSharedKeyInjection::test_stored_api_key_reaches_upstream_and_never_leaks
    • As admin, create an MCP server pointing at the X-API-Key-guarded stub mount, auth_type api_key, with the stub's expected key as auth_value
    • Open the server's info page (GET /v1/mcp/server/{id}) and confirm the auth config shows but the stored secret comes back null
    • As a regular virtual key, list the server's tools and confirm you see exactly echo and recorded_headers
    • Call echo with a unique payload and confirm it round-trips
    • Call recorded_headers and confirm the upstream saw x-api-key equal to the configured secret, and that your virtual key value appears in no header
  • tests/e2e/mcp/test_mcp_access_control_e2e.py::TestMcpServerAccessControl::test_key_without_grant_sees_no_tools_and_cannot_call
    • As admin, create two servers on the anonymous stub: one with allow_all_keys false (guarded), one with allow_all_keys true (control)
    • Create one key whose object_permission grants the guarded server, and one plain key with no grants
    • With the granted key, list the guarded server's tools (expect exactly the three stub tools) and call echo successfully
    • With the plain key, list the control server's tools to prove that key itself works
    • With the plain key, list the guarded server's tools and confirm the listing is empty
    • With the plain key, call the guarded server's echo and confirm the in-band "not allowed to call this tool" error
  • tests/e2e/mcp/test_mcp_access_control_e2e.py::TestMcpAllowedToolsFilter::test_allowed_tools_filters_listing_and_blocks_excluded_call
    • As admin, create a server on the anonymous stub (which serves echo, slow_echo, stats) with allowed_tools set to echo and stats
    • Confirm the info read-back echoes allowed_tools exactly
    • As a regular virtual key, list tools and confirm exactly echo and stats appear (slow_echo filtered out)
    • Call echo and confirm the round-trip
    • Call slow_echo and confirm the in-band "not allowed for server" error
  • tests/e2e/mcp/test_mcp_aggregate_e2e.py::TestMcpAggregateNamespace::test_one_session_lists_both_servers_and_routes_calls
    • As admin, create two servers: one on the anonymous stub mount (echo, slow_echo, stats), one on the /second mount (second_ping only)
    • As a regular virtual key, connect once to the aggregate /mcp endpoint with x-mcp-servers naming both aliases
    • List tools and confirm exactly the four alias-prefixed names across both servers
    • Call the first server's echo with a unique payload and confirm the round-trip
    • Call the second server's second_ping and confirm the reply "pong-from-second", which only the /second upstream can produce
    • Reconnect with x-mcp-servers naming only the second alias and confirm the listing shrinks to exactly second_ping

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

@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@tin-berri
tin-berri force-pushed the litellm_mcp_e2e_tests branch from d3c9670 to f503c3a Compare July 15, 2026 02:34
@tin-berri
tin-berri force-pushed the litellm_mcp_e2e_core_flows branch 8 times, most recently from f86b2e4 to 523abf6 Compare July 15, 2026 23:03
@tin-berri
tin-berri force-pushed the litellm_mcp_e2e_tests branch from a03750f to 9b144f1 Compare July 16, 2026 19:51
@tin-berri
tin-berri force-pushed the litellm_mcp_e2e_core_flows branch 2 times, most recently from a67aa98 to 8dab1be Compare July 16, 2026 20:03
@tin-berri
tin-berri force-pushed the litellm_mcp_e2e_core_flows branch 2 times, most recently from 2d4b549 to 110709c Compare July 17, 2026 00:41
@tin-berri
tin-berri force-pushed the litellm_mcp_e2e_core_flows branch from 110709c to c84cb8b Compare July 17, 2026 02:32
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.

1 participant