From cfab4f62d3d9c4253682381f64e28977cadf9e72 Mon Sep 17 00:00:00 2001 From: onatozmenn Date: Tue, 4 Aug 2026 14:59:44 +0300 Subject: [PATCH 01/12] fix(mcp): honor x-litellm-tags on the MCP gateway's tools/list and tools/call LLM routes read the header in add_litellm_data_to_request, so caller tags land in LiteLLM_SpendLogs.request_tags. Nothing read it on the MCP gateway: the tool-call handler hands that same helper a synthetic request carrying only a content type, so the header never reached the tag merge, and the list_tools spend log has a request_tags parameter no caller populates Tool calls now carry the caller's tags in the body, which that helper already reads, and list_tools falls back to the header when no tags were passed in. Per-application attribution behind a gateway that stamps the header now works the same for MCP traffic as it does for chat completions --- .../proxy/_experimental/mcp_server/server.py | 21 +- .../mcp_server/test_mcp_server.py | 192 ++++++++++++++++++ 2 files changed, 211 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 49a1f1314f0..818b05f1975 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -184,6 +184,17 @@ def _mcp_session_id_from_headers( return None +def _request_tags_from_raw_headers( + raw_headers: dict[str, str] | None, +) -> list[str] | None: + """The caller's ``x-litellm-tags``, parsed by the same helper the LLM routes use so an + MCP operation and a chat completion attribute an identical header identically.""" + if not raw_headers: + return None + headers = {key.lower(): value for key, value in raw_headers.items() if isinstance(key, str)} + return LiteLLMProxyRequestSetup.add_request_tag_to_metadata(llm_router=None, headers=headers, data={}) + + def _jsonrpc_text_has_top_level_method(text: str) -> bool: """Whether a (possibly truncated) JSON-RPC envelope has a ``method`` key at the root object's top level. @@ -1034,7 +1045,12 @@ if MCP_AVAILABLE: host_progress_callback: Final = _capture_host_progress_callback(server) # Create a body date for logging - body_data: Final = {"name": name, "arguments": arguments} + request_tags: Final = _request_tags_from_raw_headers(raw_headers) + body_data: Final = { + "name": name, + "arguments": arguments, + **({"tags": request_tags} if request_tags else {}), + } # Set trace/session id from raw_headers so spend logs and logging_obj stay consistent (same as A2A) chain_id: Final = get_chain_id_from_headers(raw_headers) if chain_id: @@ -1890,6 +1906,7 @@ if MCP_AVAILABLE: list_tools_call_id: Final = str(uuid.uuid4()) # Derive trace_id from raw_headers when not explicitly passed (same as A2A / MCP call_tool) effective_litellm_trace_id: Final = litellm_trace_id or get_chain_id_from_headers(raw_headers) + effective_request_tags: Final = request_tags or _request_tags_from_raw_headers(raw_headers) spend_logs_metadata: Final[dict[str, object]] = { "mcp_operation": "list_tools", } @@ -1905,7 +1922,7 @@ if MCP_AVAILABLE: "litellm_trace_id": effective_litellm_trace_id, "metadata": { "spend_logs_metadata": spend_logs_metadata, - **({"tags": request_tags} if request_tags else {}), + **({"tags": effective_request_tags} if effective_request_tags else {}), }, # Provide a small input payload for standard logging "input": [ diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index 850d01c6e34..39c6632f669 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -116,6 +116,48 @@ async def test_mcp_server_tool_call_body_contains_request_data(): assert body["arguments"] == tool_arguments +@pytest.mark.asyncio +async def test_mcp_server_tool_call_carries_x_litellm_tags_header_into_request_data(): + """The tool-call handler hands `add_litellm_data_to_request` a synthetic request that carries only + a content type, so the caller's `x-litellm-tags` never reached the tag merge that runs there and + the spend log for a tools/call had no tags. The tags travel in the body instead, which that same + helper already reads, so the header attributes MCP traffic exactly as it does an LLM route.""" + try: + from litellm.proxy._experimental.mcp_server.server import ( + mcp_server_tool_call, + set_auth_context, + ) + except ImportError: + pytest.skip("MCP server not available") + + set_auth_context( + UserAPIKeyAuth(api_key="test_key", user_id="test_user"), + raw_headers={"X-LiteLLM-Tags": "application:orders, service:checkout"}, + ) + + captured_data = {} + + async def mock_add_litellm_data_to_request(data, request, user_api_key_dict, proxy_config): + captured_data.update(data) + return data + + async def mock_call_mcp_tool(*args, **kwargs): + return [{"type": "text", "text": "mocked response"}] + + with patch( + "litellm.proxy.litellm_pre_call_utils.add_litellm_data_to_request", + mock_add_litellm_data_to_request, + ): + with patch( + "litellm.proxy._experimental.mcp_server.server.call_mcp_tool", + mock_call_mcp_tool, + ): + with patch("litellm.proxy.proxy_server.proxy_config", MagicMock()): + await mcp_server_tool_call("test_tool", {"param1": "value1"}) + + assert captured_data["tags"] == ["application:orders", "service:checkout"] + + @pytest.mark.asyncio async def test_mcp_server_tool_call_relays_upstream_auth_error_as_iserror(): """The MCP session manager serializes handler exceptions as JSON-RPC errors, so a mid-session @@ -4436,6 +4478,156 @@ async def test_get_tools_from_mcp_servers_logs_list_tools_to_spendlogs_when_enab assert spend_meta["per_server_list_outcomes"] == {"server_a": {"status": "ok", "tool_count": 1}} +@pytest.mark.asyncio +async def test_get_tools_from_mcp_servers_takes_list_tools_tags_from_x_litellm_tags_header(): + """A gateway that stamps `x-litellm-tags` on proxied traffic gets per-application attribution on + LLM routes; list_tools must read the same header so MCP usage is not stuck under the shared key. + Nothing populated `request_tags`, so the header was the only source and it was being dropped.""" + try: + from litellm.proxy._experimental.mcp_server.server import ( + _get_tools_from_mcp_servers, + ) + from litellm.proxy._types import UserAPIKeyAuth + from mcp.types import Tool as MCPTool + except ImportError: + pytest.skip("MCP server not available") + + user_auth = UserAPIKeyAuth(api_key="test-key", user_id="test-user") + + server_a = MagicMock(name="server_a_obj") + server_a.name = "server_a" + server_a.alias = "server_a" + server_a.server_name = "server_a" + server_a.server_id = "a" + server_a.auth_type = None + server_a.extra_headers = None + + tool_1 = MCPTool(name="server_a-tool_1", description="test tool", inputSchema={"type": "object"}) + + dummy_logging_obj = MagicMock() + dummy_logging_obj.model_call_details = {"metadata": {"spend_logs_metadata": {}}} + dummy_logging_obj.async_success_handler = AsyncMock() + function_setup_kwargs = {} + + def _capture_function_setup(*_args, **kwargs): + function_setup_kwargs.update(kwargs) + return dummy_logging_obj, None + + with ( + patch( + "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers", + new=AsyncMock(return_value=[server_a]), + ), + patch( + "litellm.proxy._experimental.mcp_server.server._prepare_mcp_server_headers", + return_value=(None, None), + ), + patch( + "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager", + ) as mock_manager, + patch( + "litellm.proxy._experimental.mcp_server.server.filter_tools_by_allowed_tools", + side_effect=lambda tools, _server: tools, + ), + patch( + "litellm.proxy._experimental.mcp_server.server.filter_tools_by_key_team_permissions", + new=AsyncMock(side_effect=lambda tools, **_: tools), + ), + patch( + "litellm.proxy._experimental.mcp_server.server.function_setup", + side_effect=_capture_function_setup, + ), + ): + mock_manager._get_tools_from_server = AsyncMock(return_value=[tool_1]) + + listing = await _get_tools_from_mcp_servers( + user_api_key_auth=user_auth, + mcp_auth_header=None, + mcp_servers=["server_a"], + mcp_server_auth_headers=None, + raw_headers={"X-LiteLLM-Tags": "application:orders, service:checkout"}, + log_list_tools_to_spendlogs=True, + list_tools_log_source="mcp_protocol", + ) + + assert listing.tools == [tool_1] + assert function_setup_kwargs["metadata"]["tags"] == ["application:orders", "service:checkout"] + + +@pytest.mark.asyncio +async def test_get_tools_from_mcp_servers_prefers_explicit_request_tags_over_the_header(): + """`request_tags` is the resolved value a caller passes in; a header must not override it.""" + try: + from litellm.proxy._experimental.mcp_server.server import ( + _get_tools_from_mcp_servers, + ) + from litellm.proxy._types import UserAPIKeyAuth + from mcp.types import Tool as MCPTool + except ImportError: + pytest.skip("MCP server not available") + + user_auth = UserAPIKeyAuth(api_key="test-key", user_id="test-user") + + server_a = MagicMock(name="server_a_obj") + server_a.name = "server_a" + server_a.alias = "server_a" + server_a.server_name = "server_a" + server_a.server_id = "a" + server_a.auth_type = None + server_a.extra_headers = None + + tool_1 = MCPTool(name="server_a-tool_1", description="test tool", inputSchema={"type": "object"}) + + dummy_logging_obj = MagicMock() + dummy_logging_obj.model_call_details = {"metadata": {"spend_logs_metadata": {}}} + dummy_logging_obj.async_success_handler = AsyncMock() + function_setup_kwargs = {} + + def _capture_function_setup(*_args, **kwargs): + function_setup_kwargs.update(kwargs) + return dummy_logging_obj, None + + with ( + patch( + "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers", + new=AsyncMock(return_value=[server_a]), + ), + patch( + "litellm.proxy._experimental.mcp_server.server._prepare_mcp_server_headers", + return_value=(None, None), + ), + patch( + "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager", + ) as mock_manager, + patch( + "litellm.proxy._experimental.mcp_server.server.filter_tools_by_allowed_tools", + side_effect=lambda tools, _server: tools, + ), + patch( + "litellm.proxy._experimental.mcp_server.server.filter_tools_by_key_team_permissions", + new=AsyncMock(side_effect=lambda tools, **_: tools), + ), + patch( + "litellm.proxy._experimental.mcp_server.server.function_setup", + side_effect=_capture_function_setup, + ), + ): + mock_manager._get_tools_from_server = AsyncMock(return_value=[tool_1]) + + await _get_tools_from_mcp_servers( + user_api_key_auth=user_auth, + mcp_auth_header=None, + mcp_servers=["server_a"], + mcp_server_auth_headers=None, + raw_headers={"x-litellm-tags": "from-header"}, + log_list_tools_to_spendlogs=True, + list_tools_log_source="mcp_protocol", + request_tags=["explicit"], + ) + + assert function_setup_kwargs["metadata"]["tags"] == ["explicit"] + + @pytest.mark.asyncio async def test_get_tools_from_mcp_servers_returns_tools_when_success_logging_fails(): """ From 56d7096f510a6b0cf2d963ce03718c3f511d02a2 Mon Sep 17 00:00:00 2001 From: onatozmenn Date: Tue, 4 Aug 2026 15:28:12 +0300 Subject: [PATCH 02/12] fix(mcp): put the caller's tag header back on the synthetic tool-call request The first pass carried the tags in the request body and built the header lookup out of new dict literals, which pushed LIT002 past its ceiling. The tool-call handler now restores the one header the tag merge actually reads onto the request it synthesizes, which is closer to the defect anyway: that request was dropping every caller header list_tools reuses the same header read, and the totals the type-discipline gate counts are back to the base --- .../proxy/_experimental/mcp_server/server.py | 44 +++++++++++++------ .../mcp_server/test_mcp_server.py | 17 ++++--- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 818b05f1975..83d682526db 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -184,15 +184,32 @@ def _mcp_session_id_from_headers( return None -def _request_tags_from_raw_headers( - raw_headers: dict[str, str] | None, -) -> list[str] | None: - """The caller's ``x-litellm-tags``, parsed by the same helper the LLM routes use so an - MCP operation and a chat completion attribute an identical header identically.""" +def _request_tags_header( + raw_headers: Mapping[str, str] | None, +) -> str | None: + """The caller's ``x-litellm-tags`` value, read case-insensitively like the other header + lookups in this module. ``None`` when the caller sent no tags.""" if not raw_headers: return None - headers = {key.lower(): value for key, value in raw_headers.items() if isinstance(key, str)} - return LiteLLMProxyRequestSetup.add_request_tag_to_metadata(llm_router=None, headers=headers, data={}) + for key, value in raw_headers.items(): + if isinstance(key, str) and key.lower() == "x-litellm-tags": + return value or None + return None + + +def _request_tags_from_raw_headers( + raw_headers: Mapping[str, str] | None, +) -> Sequence[str] | None: + """The caller's tags, parsed by the same helper the LLM routes use so an MCP operation and a + chat completion attribute an identical header identically.""" + header_value = _request_tags_header(raw_headers) + if header_value is None: + return None + return LiteLLMProxyRequestSetup.add_request_tag_to_metadata( + llm_router=None, + headers={"x-litellm-tags": header_value}, # mutable-ok: the shared parser reads a plain dict + data={}, # mutable-ok: no request body to read tags from on this path + ) def _jsonrpc_text_has_top_level_method(text: str) -> bool: @@ -1045,24 +1062,23 @@ if MCP_AVAILABLE: host_progress_callback: Final = _capture_host_progress_callback(server) # Create a body date for logging - request_tags: Final = _request_tags_from_raw_headers(raw_headers) - body_data: Final = { - "name": name, - "arguments": arguments, - **({"tags": request_tags} if request_tags else {}), - } + body_data: Final = {"name": name, "arguments": arguments} # Set trace/session id from raw_headers so spend logs and logging_obj stay consistent (same as A2A) chain_id: Final = get_chain_id_from_headers(raw_headers) if chain_id: body_data["litellm_trace_id"] = chain_id body_data["litellm_session_id"] = chain_id + tags_header: Final = _request_tags_header(raw_headers) + tags_scope_header: Final = ( + ((b"x-litellm-tags", tags_header.encode("latin-1")),) if tags_header is not None else () + ) request: Final = Request( scope={ "type": "http", "method": "POST", "path": "/mcp/tools/call", - "headers": [(b"content-type", b"application/json")], + "headers": [(b"content-type", b"application/json"), *tags_scope_header], } ) if user_api_key_auth is not None: diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index 39c6632f669..098924cee85 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -118,15 +118,16 @@ async def test_mcp_server_tool_call_body_contains_request_data(): @pytest.mark.asyncio async def test_mcp_server_tool_call_carries_x_litellm_tags_header_into_request_data(): - """The tool-call handler hands `add_litellm_data_to_request` a synthetic request that carries only - a content type, so the caller's `x-litellm-tags` never reached the tag merge that runs there and - the spend log for a tools/call had no tags. The tags travel in the body instead, which that same - helper already reads, so the header attributes MCP traffic exactly as it does an LLM route.""" + """The tool-call handler hands `add_litellm_data_to_request` a synthetic request that carried + only a content type, so the caller's `x-litellm-tags` never reached the tag merge running there + and a tools/call spend log had no tags. The synthetic request now carries the header, so the + shared parser resolves it exactly as it does on an LLM route.""" try: from litellm.proxy._experimental.mcp_server.server import ( mcp_server_tool_call, set_auth_context, ) + from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup except ImportError: pytest.skip("MCP server not available") @@ -135,10 +136,12 @@ async def test_mcp_server_tool_call_carries_x_litellm_tags_header_into_request_d raw_headers={"X-LiteLLM-Tags": "application:orders, service:checkout"}, ) - captured_data = {} + resolved_tags = {} async def mock_add_litellm_data_to_request(data, request, user_api_key_dict, proxy_config): - captured_data.update(data) + resolved_tags["tags"] = LiteLLMProxyRequestSetup.add_request_tag_to_metadata( + llm_router=None, headers=dict(request.headers), data={} + ) return data async def mock_call_mcp_tool(*args, **kwargs): @@ -155,7 +158,7 @@ async def test_mcp_server_tool_call_carries_x_litellm_tags_header_into_request_d with patch("litellm.proxy.proxy_server.proxy_config", MagicMock()): await mcp_server_tool_call("test_tool", {"param1": "value1"}) - assert captured_data["tags"] == ["application:orders", "service:checkout"] + assert resolved_tags["tags"] == ["application:orders", "service:checkout"] @pytest.mark.asyncio From f410580e1bba800918dbf6b6e558a1905c25f3cb Mon Sep 17 00:00:00 2001 From: onatozmenn Date: Tue, 4 Aug 2026 19:44:04 +0300 Subject: [PATCH 03/12] test(mcp): pin that only x-litellm-tags carries tags on the MCP gateway Codecov flagged the branch where a request carries headers but no tag header. The case matters beyond coverage: it is what stops an unrelated header from being read as tags, and it pins that an empty value attributes nothing --- .../mcp_server/test_mcp_server.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index 098924cee85..03d411e5cd3 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -4631,6 +4631,28 @@ async def test_get_tools_from_mcp_servers_prefers_explicit_request_tags_over_the assert function_setup_kwargs["metadata"]["tags"] == ["explicit"] +@pytest.mark.parametrize( + "raw_headers, expected", + [ + (None, None), + ({"mcp-session-id": "abc"}, None), + ({"x-litellm-tags": ""}, None), + ({"X-LiteLLM-Tags": "application:orders, service:checkout"}, ["application:orders", "service:checkout"]), + ], +) +def test_request_tags_from_raw_headers_only_reads_the_tag_header(raw_headers, expected): + """Only `x-litellm-tags` carries tags, whatever its casing, and an empty value is not a tag. + A request whose headers are unrelated must attribute nothing rather than the first value seen.""" + try: + from litellm.proxy._experimental.mcp_server.server import ( + _request_tags_from_raw_headers, + ) + except ImportError: + pytest.skip("MCP server not available") + + assert _request_tags_from_raw_headers(raw_headers) == expected + + @pytest.mark.asyncio async def test_get_tools_from_mcp_servers_returns_tools_when_success_logging_fails(): """ From 58582ffe716919e68ba641c464aeb18d9631e9ea Mon Sep 17 00:00:00 2001 From: onatozmenn Date: Mon, 10 Aug 2026 15:29:13 +0300 Subject: [PATCH 04/12] fix(mcp): drop the redundant isinstance in the tag header lookup The helper annotates its parameter as `Mapping[str, str]`, so basedpyright proves the key is already a `str` and counts the guard under `reportUnnecessaryIsInstance`. That took the rule to 863 against a budget of 862 and failed the lint gate. The neighbouring session-id lookup I copied the guard from carries the same diagnostic, but its count is in the base, so only this one was new --- litellm/proxy/_experimental/mcp_server/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 83d682526db..5a0cfee5c95 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -192,7 +192,7 @@ def _request_tags_header( if not raw_headers: return None for key, value in raw_headers.items(): - if isinstance(key, str) and key.lower() == "x-litellm-tags": + if key.lower() == "x-litellm-tags": return value or None return None From 12075b56e08bb473e0823d837775c434138157fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Onat=20=C3=96zmen?= Date: Sun, 30 Aug 2026 22:45:34 +0300 Subject: [PATCH 05/12] chore: resolve PR 35777 merge conflicts --- .github/workflows/resolve-pr-35777.yml | 204 +++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 .github/workflows/resolve-pr-35777.yml diff --git a/.github/workflows/resolve-pr-35777.yml b/.github/workflows/resolve-pr-35777.yml new file mode 100644 index 00000000000..a8c68954770 --- /dev/null +++ b/.github/workflows/resolve-pr-35777.yml @@ -0,0 +1,204 @@ +name: Resolve PR 35777 conflicts + +on: + push: + branches: + - litellm_mcp_gateway_request_tags + +permissions: + contents: write + +jobs: + resolve: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: litellm_mcp_gateway_request_tags + + - name: Merge latest staging and preserve MCP request tags fix + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git remote add upstream https://github.com/BerriAI/litellm.git 2>/dev/null || true + git fetch upstream litellm_internal_staging + + git merge --no-commit --no-ff upstream/litellm_internal_staging || true + + # Both files changed substantially upstream. Start from current staging, + # then re-apply only the part of #35777 that is still needed. + git checkout upstream/litellm_internal_staging -- \ + litellm/proxy/_experimental/mcp_server/server.py \ + tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py + + python - <<'PY' + from pathlib import Path + + server_path = Path("litellm/proxy/_experimental/mcp_server/server.py") + text = server_path.read_text() + + helper_anchor = "\n\ndef _jsonrpc_text_has_top_level_method(text: str) -> bool:\n" + helper = ''' + + +def _request_tags_from_raw_headers( + raw_headers: Mapping[str, str] | None, +) -> Sequence[str] | None: + """Parse the caller's x-litellm-tags header with the shared proxy tag parser.""" + if not raw_headers: + return None + for key, value in raw_headers.items(): + if isinstance(key, str) and key.lower() == "x-litellm-tags" and value: + return LiteLLMProxyRequestSetup.add_request_tag_to_metadata( + llm_router=None, + headers={"x-litellm-tags": value}, + data={}, + ) + return None +''' + if "def _request_tags_from_raw_headers(" not in text: + if helper_anchor not in text: + raise SystemExit("helper anchor not found") + text = text.replace(helper_anchor, helper + helper_anchor, 1) + + old = " effective_litellm_trace_id: Final = litellm_trace_id or get_chain_id_from_headers(raw_headers)\n spend_logs_metadata: Final[dict[str, object]] = {\n" + new = " effective_litellm_trace_id: Final = litellm_trace_id or get_chain_id_from_headers(raw_headers)\n effective_request_tags: Final = request_tags or _request_tags_from_raw_headers(raw_headers)\n spend_logs_metadata: Final[dict[str, object]] = {\n" + if "effective_request_tags: Final" not in text: + if old not in text: + raise SystemExit("list-tools logging anchor not found") + text = text.replace(old, new, 1) + + old_tags = ' **({"tags": request_tags} if request_tags else {}),\n' + new_tags = ' **({"tags": effective_request_tags} if effective_request_tags else {}),\n' + if old_tags in text: + text = text.replace(old_tags, new_tags, 1) + elif new_tags not in text: + raise SystemExit("tags metadata anchor not found") + + server_path.write_text(text) + + test_path = Path("tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py") + tests = test_path.read_text() + marker = "test_request_tags_from_raw_headers_reads_x_litellm_tags" + if marker not in tests: + tests += r''' + + +@pytest.mark.parametrize( + "raw_headers, expected", + [ + (None, None), + ({"mcp-session-id": "abc"}, None), + ({"x-litellm-tags": ""}, None), + ( + {"X-LiteLLM-Tags": "application:orders, service:checkout"}, + ["application:orders", "service:checkout"], + ), + ], +) +def test_request_tags_from_raw_headers_reads_x_litellm_tags(raw_headers, expected): + from litellm.proxy._experimental.mcp_server.server import ( + _request_tags_from_raw_headers, + ) + + assert _request_tags_from_raw_headers(raw_headers) == expected + + +@pytest.mark.asyncio +async def test_get_tools_from_mcp_servers_uses_x_litellm_tags_for_spend_logging(): + from litellm.proxy._experimental.mcp_server.server import ( + _get_tools_from_mcp_servers, + ) + from mcp.types import Tool as MCPTool + + user_auth = UserAPIKeyAuth(api_key="test-key", user_id="test-user") + + server_a = MagicMock(name="server_a_obj") + server_a.name = "server_a" + server_a.alias = "server_a" + server_a.server_name = "server_a" + server_a.server_id = "a" + server_a.auth_type = None + server_a.extra_headers = None + server_a.tool_name_to_display_name = None + server_a.tool_name_to_description = None + + tool_1 = MCPTool( + name="server_a-tool_1", + description="test tool", + inputSchema={"type": "object"}, + ) + + dummy_logging_obj = MagicMock() + dummy_logging_obj.model_call_details = {"metadata": {"spend_logs_metadata": {}}} + dummy_logging_obj.async_success_handler = AsyncMock() + function_setup_kwargs = {} + + def _capture_function_setup(*_args, **kwargs): + function_setup_kwargs.update(kwargs) + return dummy_logging_obj, None + + with ( + patch( + "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers", + new=AsyncMock(return_value=[server_a]), + ), + patch( + "litellm.proxy._experimental.mcp_server.server._prepare_mcp_server_headers", + return_value=(None, None), + ), + patch( + "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager", + ) as mock_manager, + patch( + "litellm.proxy._experimental.mcp_server.server.filter_tools_by_allowed_tools", + side_effect=lambda tools, _server: tools, + ), + patch( + "litellm.proxy._experimental.mcp_server.server.filter_tools_by_key_team_permissions", + new=AsyncMock(side_effect=lambda tools, **_: tools), + ), + patch( + "litellm.proxy._experimental.mcp_server.server.function_setup", + side_effect=_capture_function_setup, + ), + ): + mock_manager._get_tools_from_server = AsyncMock(return_value=[tool_1]) + + listing = await _get_tools_from_mcp_servers( + user_api_key_auth=user_auth, + mcp_auth_header=None, + mcp_servers=["server_a"], + mcp_server_auth_headers=None, + raw_headers={"X-LiteLLM-Tags": "application:orders, service:checkout"}, + log_list_tools_to_spendlogs=True, + list_tools_log_source="mcp_protocol", + ) + + assert listing.tools == [tool_1] + assert function_setup_kwargs["metadata"]["tags"] == [ + "application:orders", + "service:checkout", + ] +''' + test_path.write_text(tests) + PY + + # This workflow is only a one-shot resolver and must not remain in the PR tree. + git rm .github/workflows/resolve-pr-35777.yml + git add \ + litellm/proxy/_experimental/mcp_server/server.py \ + tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py + + if git ls-files -u | grep -q .; then + echo "Unresolved merge entries remain:" >&2 + git ls-files -u >&2 + exit 1 + fi + + git commit -m "Merge litellm_internal_staging and resolve MCP request-tags conflicts" + git push origin HEAD:litellm_mcp_gateway_request_tags From 3dd3297cd61593121a3efdc5c57b66dc80fae153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Onat=20=C3=96zmen?= Date: Sun, 30 Aug 2026 22:47:29 +0300 Subject: [PATCH 06/12] chore: add temporary conflict resolver script --- .github/resolve_pr35777.py | 150 +++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 .github/resolve_pr35777.py diff --git a/.github/resolve_pr35777.py b/.github/resolve_pr35777.py new file mode 100644 index 00000000000..b706165aa06 --- /dev/null +++ b/.github/resolve_pr35777.py @@ -0,0 +1,150 @@ +from pathlib import Path + +server_path = Path("litellm/proxy/_experimental/mcp_server/server.py") +text = server_path.read_text() + +helper_anchor = "\n\ndef _jsonrpc_text_has_top_level_method(text: str) -> bool:\n" +helper = ''' + + +def _request_tags_from_raw_headers( + raw_headers: Mapping[str, str] | None, +) -> Sequence[str] | None: + """Parse the caller's x-litellm-tags header with the shared proxy tag parser.""" + if not raw_headers: + return None + for key, value in raw_headers.items(): + if isinstance(key, str) and key.lower() == "x-litellm-tags" and value: + return LiteLLMProxyRequestSetup.add_request_tag_to_metadata( + llm_router=None, + headers={"x-litellm-tags": value}, + data={}, + ) + return None +''' +if "def _request_tags_from_raw_headers(" not in text: + if helper_anchor not in text: + raise SystemExit("helper anchor not found") + text = text.replace(helper_anchor, helper + helper_anchor, 1) + +old = " effective_litellm_trace_id: Final = litellm_trace_id or get_chain_id_from_headers(raw_headers)\n spend_logs_metadata: Final[dict[str, object]] = {\n" +new = " effective_litellm_trace_id: Final = litellm_trace_id or get_chain_id_from_headers(raw_headers)\n effective_request_tags: Final = request_tags or _request_tags_from_raw_headers(raw_headers)\n spend_logs_metadata: Final[dict[str, object]] = {\n" +if "effective_request_tags: Final" not in text: + if old not in text: + raise SystemExit("list-tools logging anchor not found") + text = text.replace(old, new, 1) + +old_tags = ' **({"tags": request_tags} if request_tags else {}),\n' +new_tags = ' **({"tags": effective_request_tags} if effective_request_tags else {}),\n' +if old_tags in text: + text = text.replace(old_tags, new_tags, 1) +elif new_tags not in text: + raise SystemExit("tags metadata anchor not found") + +server_path.write_text(text) + +test_path = Path("tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py") +tests = test_path.read_text() +marker = "test_request_tags_from_raw_headers_reads_x_litellm_tags" +if marker not in tests: + tests += r''' + + +@pytest.mark.parametrize( + "raw_headers, expected", + [ + (None, None), + ({"mcp-session-id": "abc"}, None), + ({"x-litellm-tags": ""}, None), + ( + {"X-LiteLLM-Tags": "application:orders, service:checkout"}, + ["application:orders", "service:checkout"], + ), + ], +) +def test_request_tags_from_raw_headers_reads_x_litellm_tags(raw_headers, expected): + from litellm.proxy._experimental.mcp_server.server import ( + _request_tags_from_raw_headers, + ) + + assert _request_tags_from_raw_headers(raw_headers) == expected + + +@pytest.mark.asyncio +async def test_get_tools_from_mcp_servers_uses_x_litellm_tags_for_spend_logging(): + from litellm.proxy._experimental.mcp_server.server import ( + _get_tools_from_mcp_servers, + ) + from mcp.types import Tool as MCPTool + + user_auth = UserAPIKeyAuth(api_key="test-key", user_id="test-user") + + server_a = MagicMock(name="server_a_obj") + server_a.name = "server_a" + server_a.alias = "server_a" + server_a.server_name = "server_a" + server_a.server_id = "a" + server_a.auth_type = None + server_a.extra_headers = None + server_a.tool_name_to_display_name = None + server_a.tool_name_to_description = None + + tool_1 = MCPTool( + name="server_a-tool_1", + description="test tool", + inputSchema={"type": "object"}, + ) + + dummy_logging_obj = MagicMock() + dummy_logging_obj.model_call_details = {"metadata": {"spend_logs_metadata": {}}} + dummy_logging_obj.async_success_handler = AsyncMock() + function_setup_kwargs = {} + + def _capture_function_setup(*_args, **kwargs): + function_setup_kwargs.update(kwargs) + return dummy_logging_obj, None + + with ( + patch( + "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers", + new=AsyncMock(return_value=[server_a]), + ), + patch( + "litellm.proxy._experimental.mcp_server.server._prepare_mcp_server_headers", + return_value=(None, None), + ), + patch( + "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager", + ) as mock_manager, + patch( + "litellm.proxy._experimental.mcp_server.server.filter_tools_by_allowed_tools", + side_effect=lambda tools, _server: tools, + ), + patch( + "litellm.proxy._experimental.mcp_server.server.filter_tools_by_key_team_permissions", + new=AsyncMock(side_effect=lambda tools, **_: tools), + ), + patch( + "litellm.proxy._experimental.mcp_server.server.function_setup", + side_effect=_capture_function_setup, + ), + ): + mock_manager._get_tools_from_server = AsyncMock(return_value=[tool_1]) + + listing = await _get_tools_from_mcp_servers( + user_api_key_auth=user_auth, + mcp_auth_header=None, + mcp_servers=["server_a"], + mcp_server_auth_headers=None, + raw_headers={"X-LiteLLM-Tags": "application:orders, service:checkout"}, + log_list_tools_to_spendlogs=True, + list_tools_log_source="mcp_protocol", + ) + + assert listing.tools == [tool_1] + assert function_setup_kwargs["metadata"]["tags"] == [ + "application:orders", + "service:checkout", + ] +''' + test_path.write_text(tests) From 710ee60bf04d64386c3e9a8658fee9bd9f17c7c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Onat=20=C3=96zmen?= Date: Sun, 30 Aug 2026 22:47:38 +0300 Subject: [PATCH 07/12] chore: simplify PR 35777 conflict resolver --- .github/workflows/resolve-pr-35777.yml | 193 ++----------------------- 1 file changed, 11 insertions(+), 182 deletions(-) diff --git a/.github/workflows/resolve-pr-35777.yml b/.github/workflows/resolve-pr-35777.yml index a8c68954770..16483c5232c 100644 --- a/.github/workflows/resolve-pr-35777.yml +++ b/.github/workflows/resolve-pr-35777.yml @@ -2,203 +2,32 @@ name: Resolve PR 35777 conflicts on: push: - branches: - - litellm_mcp_gateway_request_tags + branches: [litellm_mcp_gateway_request_tags] + paths: + - .github/workflows/resolve-pr-35777.yml permissions: contents: write jobs: resolve: - if: github.actor != 'github-actions[bot]' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 ref: litellm_mcp_gateway_request_tags - - - name: Merge latest staging and preserve MCP request tags fix - shell: bash - run: | - set -euo pipefail + - run: | + set -e git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git remote add upstream https://github.com/BerriAI/litellm.git 2>/dev/null || true + git remote add upstream https://github.com/BerriAI/litellm.git || true git fetch upstream litellm_internal_staging - git merge --no-commit --no-ff upstream/litellm_internal_staging || true - - # Both files changed substantially upstream. Start from current staging, - # then re-apply only the part of #35777 that is still needed. - git checkout upstream/litellm_internal_staging -- \ - litellm/proxy/_experimental/mcp_server/server.py \ - tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py - - python - <<'PY' - from pathlib import Path - - server_path = Path("litellm/proxy/_experimental/mcp_server/server.py") - text = server_path.read_text() - - helper_anchor = "\n\ndef _jsonrpc_text_has_top_level_method(text: str) -> bool:\n" - helper = ''' - - -def _request_tags_from_raw_headers( - raw_headers: Mapping[str, str] | None, -) -> Sequence[str] | None: - """Parse the caller's x-litellm-tags header with the shared proxy tag parser.""" - if not raw_headers: - return None - for key, value in raw_headers.items(): - if isinstance(key, str) and key.lower() == "x-litellm-tags" and value: - return LiteLLMProxyRequestSetup.add_request_tag_to_metadata( - llm_router=None, - headers={"x-litellm-tags": value}, - data={}, - ) - return None -''' - if "def _request_tags_from_raw_headers(" not in text: - if helper_anchor not in text: - raise SystemExit("helper anchor not found") - text = text.replace(helper_anchor, helper + helper_anchor, 1) - - old = " effective_litellm_trace_id: Final = litellm_trace_id or get_chain_id_from_headers(raw_headers)\n spend_logs_metadata: Final[dict[str, object]] = {\n" - new = " effective_litellm_trace_id: Final = litellm_trace_id or get_chain_id_from_headers(raw_headers)\n effective_request_tags: Final = request_tags or _request_tags_from_raw_headers(raw_headers)\n spend_logs_metadata: Final[dict[str, object]] = {\n" - if "effective_request_tags: Final" not in text: - if old not in text: - raise SystemExit("list-tools logging anchor not found") - text = text.replace(old, new, 1) - - old_tags = ' **({"tags": request_tags} if request_tags else {}),\n' - new_tags = ' **({"tags": effective_request_tags} if effective_request_tags else {}),\n' - if old_tags in text: - text = text.replace(old_tags, new_tags, 1) - elif new_tags not in text: - raise SystemExit("tags metadata anchor not found") - - server_path.write_text(text) - - test_path = Path("tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py") - tests = test_path.read_text() - marker = "test_request_tags_from_raw_headers_reads_x_litellm_tags" - if marker not in tests: - tests += r''' - - -@pytest.mark.parametrize( - "raw_headers, expected", - [ - (None, None), - ({"mcp-session-id": "abc"}, None), - ({"x-litellm-tags": ""}, None), - ( - {"X-LiteLLM-Tags": "application:orders, service:checkout"}, - ["application:orders", "service:checkout"], - ), - ], -) -def test_request_tags_from_raw_headers_reads_x_litellm_tags(raw_headers, expected): - from litellm.proxy._experimental.mcp_server.server import ( - _request_tags_from_raw_headers, - ) - - assert _request_tags_from_raw_headers(raw_headers) == expected - - -@pytest.mark.asyncio -async def test_get_tools_from_mcp_servers_uses_x_litellm_tags_for_spend_logging(): - from litellm.proxy._experimental.mcp_server.server import ( - _get_tools_from_mcp_servers, - ) - from mcp.types import Tool as MCPTool - - user_auth = UserAPIKeyAuth(api_key="test-key", user_id="test-user") - - server_a = MagicMock(name="server_a_obj") - server_a.name = "server_a" - server_a.alias = "server_a" - server_a.server_name = "server_a" - server_a.server_id = "a" - server_a.auth_type = None - server_a.extra_headers = None - server_a.tool_name_to_display_name = None - server_a.tool_name_to_description = None - - tool_1 = MCPTool( - name="server_a-tool_1", - description="test tool", - inputSchema={"type": "object"}, - ) - - dummy_logging_obj = MagicMock() - dummy_logging_obj.model_call_details = {"metadata": {"spend_logs_metadata": {}}} - dummy_logging_obj.async_success_handler = AsyncMock() - function_setup_kwargs = {} - - def _capture_function_setup(*_args, **kwargs): - function_setup_kwargs.update(kwargs) - return dummy_logging_obj, None - - with ( - patch( - "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers", - new=AsyncMock(return_value=[server_a]), - ), - patch( - "litellm.proxy._experimental.mcp_server.server._prepare_mcp_server_headers", - return_value=(None, None), - ), - patch( - "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager", - ) as mock_manager, - patch( - "litellm.proxy._experimental.mcp_server.server.filter_tools_by_allowed_tools", - side_effect=lambda tools, _server: tools, - ), - patch( - "litellm.proxy._experimental.mcp_server.server.filter_tools_by_key_team_permissions", - new=AsyncMock(side_effect=lambda tools, **_: tools), - ), - patch( - "litellm.proxy._experimental.mcp_server.server.function_setup", - side_effect=_capture_function_setup, - ), - ): - mock_manager._get_tools_from_server = AsyncMock(return_value=[tool_1]) - - listing = await _get_tools_from_mcp_servers( - user_api_key_auth=user_auth, - mcp_auth_header=None, - mcp_servers=["server_a"], - mcp_server_auth_headers=None, - raw_headers={"X-LiteLLM-Tags": "application:orders, service:checkout"}, - log_list_tools_to_spendlogs=True, - list_tools_log_source="mcp_protocol", - ) - - assert listing.tools == [tool_1] - assert function_setup_kwargs["metadata"]["tags"] == [ - "application:orders", - "service:checkout", - ] -''' - test_path.write_text(tests) - PY - - # This workflow is only a one-shot resolver and must not remain in the PR tree. - git rm .github/workflows/resolve-pr-35777.yml - git add \ - litellm/proxy/_experimental/mcp_server/server.py \ - tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py - - if git ls-files -u | grep -q .; then - echo "Unresolved merge entries remain:" >&2 - git ls-files -u >&2 - exit 1 - fi - + git checkout upstream/litellm_internal_staging -- litellm/proxy/_experimental/mcp_server/server.py tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py + python .github/resolve_pr35777.py + git rm .github/workflows/resolve-pr-35777.yml .github/resolve_pr35777.py + git add litellm/proxy/_experimental/mcp_server/server.py tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py + test -z "$(git ls-files -u)" git commit -m "Merge litellm_internal_staging and resolve MCP request-tags conflicts" git push origin HEAD:litellm_mcp_gateway_request_tags From 53ce40a875e2243ca2f3aec138e2952ca2c7ec11 Mon Sep 17 00:00:00 2001 From: onatozmenn Date: Sun, 13 Sep 2026 23:53:27 +0300 Subject: [PATCH 08/12] chore(mcp): remove temporary PR 35777 conflict resolver workflow and script - Veria flagged contents:write on push-triggered branch workflow --- .github/resolve_pr35777.py | 150 ------------------------- .github/workflows/resolve-pr-35777.yml | 33 ------ 2 files changed, 183 deletions(-) delete mode 100644 .github/resolve_pr35777.py delete mode 100644 .github/workflows/resolve-pr-35777.yml diff --git a/.github/resolve_pr35777.py b/.github/resolve_pr35777.py deleted file mode 100644 index b706165aa06..00000000000 --- a/.github/resolve_pr35777.py +++ /dev/null @@ -1,150 +0,0 @@ -from pathlib import Path - -server_path = Path("litellm/proxy/_experimental/mcp_server/server.py") -text = server_path.read_text() - -helper_anchor = "\n\ndef _jsonrpc_text_has_top_level_method(text: str) -> bool:\n" -helper = ''' - - -def _request_tags_from_raw_headers( - raw_headers: Mapping[str, str] | None, -) -> Sequence[str] | None: - """Parse the caller's x-litellm-tags header with the shared proxy tag parser.""" - if not raw_headers: - return None - for key, value in raw_headers.items(): - if isinstance(key, str) and key.lower() == "x-litellm-tags" and value: - return LiteLLMProxyRequestSetup.add_request_tag_to_metadata( - llm_router=None, - headers={"x-litellm-tags": value}, - data={}, - ) - return None -''' -if "def _request_tags_from_raw_headers(" not in text: - if helper_anchor not in text: - raise SystemExit("helper anchor not found") - text = text.replace(helper_anchor, helper + helper_anchor, 1) - -old = " effective_litellm_trace_id: Final = litellm_trace_id or get_chain_id_from_headers(raw_headers)\n spend_logs_metadata: Final[dict[str, object]] = {\n" -new = " effective_litellm_trace_id: Final = litellm_trace_id or get_chain_id_from_headers(raw_headers)\n effective_request_tags: Final = request_tags or _request_tags_from_raw_headers(raw_headers)\n spend_logs_metadata: Final[dict[str, object]] = {\n" -if "effective_request_tags: Final" not in text: - if old not in text: - raise SystemExit("list-tools logging anchor not found") - text = text.replace(old, new, 1) - -old_tags = ' **({"tags": request_tags} if request_tags else {}),\n' -new_tags = ' **({"tags": effective_request_tags} if effective_request_tags else {}),\n' -if old_tags in text: - text = text.replace(old_tags, new_tags, 1) -elif new_tags not in text: - raise SystemExit("tags metadata anchor not found") - -server_path.write_text(text) - -test_path = Path("tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py") -tests = test_path.read_text() -marker = "test_request_tags_from_raw_headers_reads_x_litellm_tags" -if marker not in tests: - tests += r''' - - -@pytest.mark.parametrize( - "raw_headers, expected", - [ - (None, None), - ({"mcp-session-id": "abc"}, None), - ({"x-litellm-tags": ""}, None), - ( - {"X-LiteLLM-Tags": "application:orders, service:checkout"}, - ["application:orders", "service:checkout"], - ), - ], -) -def test_request_tags_from_raw_headers_reads_x_litellm_tags(raw_headers, expected): - from litellm.proxy._experimental.mcp_server.server import ( - _request_tags_from_raw_headers, - ) - - assert _request_tags_from_raw_headers(raw_headers) == expected - - -@pytest.mark.asyncio -async def test_get_tools_from_mcp_servers_uses_x_litellm_tags_for_spend_logging(): - from litellm.proxy._experimental.mcp_server.server import ( - _get_tools_from_mcp_servers, - ) - from mcp.types import Tool as MCPTool - - user_auth = UserAPIKeyAuth(api_key="test-key", user_id="test-user") - - server_a = MagicMock(name="server_a_obj") - server_a.name = "server_a" - server_a.alias = "server_a" - server_a.server_name = "server_a" - server_a.server_id = "a" - server_a.auth_type = None - server_a.extra_headers = None - server_a.tool_name_to_display_name = None - server_a.tool_name_to_description = None - - tool_1 = MCPTool( - name="server_a-tool_1", - description="test tool", - inputSchema={"type": "object"}, - ) - - dummy_logging_obj = MagicMock() - dummy_logging_obj.model_call_details = {"metadata": {"spend_logs_metadata": {}}} - dummy_logging_obj.async_success_handler = AsyncMock() - function_setup_kwargs = {} - - def _capture_function_setup(*_args, **kwargs): - function_setup_kwargs.update(kwargs) - return dummy_logging_obj, None - - with ( - patch( - "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers", - new=AsyncMock(return_value=[server_a]), - ), - patch( - "litellm.proxy._experimental.mcp_server.server._prepare_mcp_server_headers", - return_value=(None, None), - ), - patch( - "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager", - ) as mock_manager, - patch( - "litellm.proxy._experimental.mcp_server.server.filter_tools_by_allowed_tools", - side_effect=lambda tools, _server: tools, - ), - patch( - "litellm.proxy._experimental.mcp_server.server.filter_tools_by_key_team_permissions", - new=AsyncMock(side_effect=lambda tools, **_: tools), - ), - patch( - "litellm.proxy._experimental.mcp_server.server.function_setup", - side_effect=_capture_function_setup, - ), - ): - mock_manager._get_tools_from_server = AsyncMock(return_value=[tool_1]) - - listing = await _get_tools_from_mcp_servers( - user_api_key_auth=user_auth, - mcp_auth_header=None, - mcp_servers=["server_a"], - mcp_server_auth_headers=None, - raw_headers={"X-LiteLLM-Tags": "application:orders, service:checkout"}, - log_list_tools_to_spendlogs=True, - list_tools_log_source="mcp_protocol", - ) - - assert listing.tools == [tool_1] - assert function_setup_kwargs["metadata"]["tags"] == [ - "application:orders", - "service:checkout", - ] -''' - test_path.write_text(tests) diff --git a/.github/workflows/resolve-pr-35777.yml b/.github/workflows/resolve-pr-35777.yml deleted file mode 100644 index 16483c5232c..00000000000 --- a/.github/workflows/resolve-pr-35777.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Resolve PR 35777 conflicts - -on: - push: - branches: [litellm_mcp_gateway_request_tags] - paths: - - .github/workflows/resolve-pr-35777.yml - -permissions: - contents: write - -jobs: - resolve: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: litellm_mcp_gateway_request_tags - - run: | - set -e - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git remote add upstream https://github.com/BerriAI/litellm.git || true - git fetch upstream litellm_internal_staging - git merge --no-commit --no-ff upstream/litellm_internal_staging || true - git checkout upstream/litellm_internal_staging -- litellm/proxy/_experimental/mcp_server/server.py tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py - python .github/resolve_pr35777.py - git rm .github/workflows/resolve-pr-35777.yml .github/resolve_pr35777.py - git add litellm/proxy/_experimental/mcp_server/server.py tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py - test -z "$(git ls-files -u)" - git commit -m "Merge litellm_internal_staging and resolve MCP request-tags conflicts" - git push origin HEAD:litellm_mcp_gateway_request_tags From 36b1a22f0395604bd020bcdeb883f3f5f8eb1ca4 Mon Sep 17 00:00:00 2001 From: onatozmenn Date: Mon, 14 Sep 2026 00:09:29 +0300 Subject: [PATCH 09/12] fix(mcp): address Copilot review and CI gates on request-tags PR - explicit [] no longer falls back to header (is not None), Final on header_value, TQ008 suppressions on new patches, sync stale schema.d.ts soft_budget removal --- .../proxy/_experimental/mcp_server/server.py | 8 +++- .../mcp_server/test_mcp_server.py | 43 +++++++++++++------ ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 - 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 1bd23db0948..6a5e55d1822 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -213,7 +213,7 @@ def _request_tags_from_raw_headers( ) -> Sequence[str] | None: """The caller's tags, parsed by the same helper the LLM routes use so an MCP operation and a chat completion attribute an identical header identically.""" - header_value = _request_tags_header(raw_headers) + header_value: Final = _request_tags_header(raw_headers) if header_value is None: return None return LiteLLMProxyRequestSetup.add_request_tag_to_metadata( @@ -2190,7 +2190,11 @@ if MCP_AVAILABLE: list_tools_call_id: Final = str(uuid.uuid4()) # Derive trace_id from raw_headers when not explicitly passed (same as A2A / MCP call_tool) effective_litellm_trace_id: Final = litellm_trace_id or get_chain_id_from_headers(raw_headers) - effective_request_tags: Final = request_tags or _request_tags_from_raw_headers(raw_headers) + # An explicit [] means the caller resolved to no tags; only fall back to the + # header when nothing was passed at all. + effective_request_tags: Final = ( + request_tags if request_tags is not None else _request_tags_from_raw_headers(raw_headers) + ) spend_logs_metadata: Final[dict[str, object]] = { "mcp_operation": "list_tools", } diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index 09d7592d4d4..1b68303cfdb 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -5031,26 +5031,26 @@ async def test_get_tools_from_mcp_servers_takes_list_tools_tags_from_x_litellm_t return dummy_logging_obj, None with ( - patch( + patch( # test-quality-ok: server allowlist is a module-level function; the suite has no injection seam "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers", new=AsyncMock(return_value=[server_a]), ), - patch( + patch( # test-quality-ok: header prep is a module-level function; the suite has no injection seam "litellm.proxy._experimental.mcp_server.server._prepare_mcp_server_headers", return_value=(None, None), ), - patch( + patch( # test-quality-ok: manager is a module-level singleton; patching it is the suite established seam "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager", ) as mock_manager, - patch( + patch( # test-quality-ok: tool filter is a module-level function; the suite has no injection seam "litellm.proxy._experimental.mcp_server.server.filter_tools_by_allowed_tools", side_effect=lambda tools, _server: tools, ), - patch( + patch( # test-quality-ok: permission filter is a module-level async function; the suite has no injection seam "litellm.proxy._experimental.mcp_server.server.filter_tools_by_key_team_permissions", new=AsyncMock(side_effect=lambda tools, **_: tools), ), - patch( + patch( # test-quality-ok: logging setup is a module-level function; patched to capture spend-log metadata kwargs "litellm.proxy._experimental.mcp_server.server.function_setup", side_effect=_capture_function_setup, ), @@ -5073,7 +5073,8 @@ async def test_get_tools_from_mcp_servers_takes_list_tools_tags_from_x_litellm_t @pytest.mark.asyncio async def test_get_tools_from_mcp_servers_prefers_explicit_request_tags_over_the_header(): - """`request_tags` is the resolved value a caller passes in; a header must not override it.""" + """`request_tags` is the resolved value a caller passes in; a header must not override it. + An explicit empty list resolves to no tags rather than falling back to the header.""" try: from litellm.proxy._experimental.mcp_server.server import ( _get_tools_from_mcp_servers, @@ -5105,26 +5106,26 @@ async def test_get_tools_from_mcp_servers_prefers_explicit_request_tags_over_the return dummy_logging_obj, None with ( - patch( + patch( # test-quality-ok: server allowlist is a module-level function; the suite has no injection seam "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers", new=AsyncMock(return_value=[server_a]), ), - patch( + patch( # test-quality-ok: header prep is a module-level function; the suite has no injection seam "litellm.proxy._experimental.mcp_server.server._prepare_mcp_server_headers", return_value=(None, None), ), - patch( + patch( # test-quality-ok: manager is a module-level singleton; patching it is the suite established seam "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager", ) as mock_manager, - patch( + patch( # test-quality-ok: tool filter is a module-level function; the suite has no injection seam "litellm.proxy._experimental.mcp_server.server.filter_tools_by_allowed_tools", side_effect=lambda tools, _server: tools, ), - patch( + patch( # test-quality-ok: permission filter is a module-level async function; the suite has no injection seam "litellm.proxy._experimental.mcp_server.server.filter_tools_by_key_team_permissions", new=AsyncMock(side_effect=lambda tools, **_: tools), ), - patch( + patch( # test-quality-ok: logging setup is a module-level function; patched to capture spend-log metadata kwargs "litellm.proxy._experimental.mcp_server.server.function_setup", side_effect=_capture_function_setup, ), @@ -5141,8 +5142,22 @@ async def test_get_tools_from_mcp_servers_prefers_explicit_request_tags_over_the list_tools_log_source="mcp_protocol", request_tags=["explicit"], ) + explicit_metadata = dict(function_setup_kwargs["metadata"]) - assert function_setup_kwargs["metadata"]["tags"] == ["explicit"] + await _get_tools_from_mcp_servers( + user_api_key_auth=user_auth, + mcp_auth_header=None, + mcp_servers=["server_a"], + mcp_server_auth_headers=None, + raw_headers={"x-litellm-tags": "from-header"}, + log_list_tools_to_spendlogs=True, + list_tools_log_source="mcp_protocol", + request_tags=[], + ) + empty_metadata = dict(function_setup_kwargs["metadata"]) + + assert explicit_metadata["tags"] == ["explicit"] + assert "tags" not in empty_metadata @pytest.mark.parametrize( diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 7eadaa6c991..839aa52fa84 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -16781,7 +16781,6 @@ export interface paths { * - permissions: Optional[dict] - [Not Implemented Yet] User-specific permissions, eg. turning off pii masking. * - metadata: Optional[dict] - Metadata for user, store information for user. Example metadata = {"team": "core-infra", "app": "app2", "email": "ishaan@berri.ai" } * - max_parallel_requests: Optional[int] - Rate limit a user based on the number of parallel requests. Raises 429 error, if user's parallel requests > x. - * - soft_budget: Optional[float] - Get alerts when user crosses given budget, doesn't block requests. * - model_max_budget: Optional[dict] - Model-specific max budget for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-budgets-to-keys) * - budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}. * - model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys) @@ -16887,7 +16886,6 @@ export interface paths { * - permissions: Optional[dict] - [Not Implemented Yet] User-specific permissions, eg. turning off pii masking. * - metadata: Optional[dict] - Metadata for user, store information for user. Example metadata = {"team": "core-infra", "app": "app2", "email": "ishaan@berri.ai" } * - max_parallel_requests: Optional[int] - Rate limit a user based on the number of parallel requests. Raises 429 error, if user's parallel requests > x. - * - soft_budget: Optional[float] - Get alerts when user crosses given budget, doesn't block requests. * - model_max_budget: Optional[dict] - Model-specific max budget for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-budgets-to-keys) * - budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}. * - model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys) From 708f388788260190ccf01cb137cd136ee51f0a41 Mon Sep 17 00:00:00 2001 From: onatozmenn Date: Mon, 14 Sep 2026 01:21:38 +0300 Subject: [PATCH 10/12] fix(auth): cache prefetched org entries with the management TTL - the 5s default fuse expired before slow requests reached get_org_object, sending them to the DB (proxy-behavior auth prefetch test failed under load with dead_db TypeError) --- litellm/proxy/auth/auth_object_prefetch.py | 8 +++++--- .../test_litellm/proxy/auth/test_auth_object_prefetch.py | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/auth/auth_object_prefetch.py b/litellm/proxy/auth/auth_object_prefetch.py index 52e26e885c9..503223e7d87 100644 --- a/litellm/proxy/auth/auth_object_prefetch.py +++ b/litellm/proxy/auth/auth_object_prefetch.py @@ -14,7 +14,6 @@ from pydantic import BaseModel, TypeAdapter, ValidationError from litellm._logging import verbose_proxy_logger from litellm.caching.redis_cache import RedisCache -from litellm.constants import DEFAULT_IN_MEMORY_TTL from litellm.models.organization import LiteLLM_OrganizationTable from litellm.models.team import LiteLLM_TeamTableCachedObj from litellm.models.team_membership import LiteLLM_TeamMembership @@ -190,14 +189,17 @@ def _iter_entries(refs: AuthObjectRefs, management_ttl: float) -> Iterator[_Cach None, ) if refs.organization_id is not None: + # Organization entries use the management TTL like every other prefetched object: a + # 5s fuse expires before slow requests reach the getters, sending them back to + # the DB the prefetch was meant to spare. yield _CacheEntry( - f"org_id:{refs.organization_id}", "organization_row", LiteLLM_OrganizationTable, DEFAULT_IN_MEMORY_TTL + f"org_id:{refs.organization_id}", "organization_row", LiteLLM_OrganizationTable, management_ttl ) yield _CacheEntry( f"org_id:{refs.organization_id}:with_budget", "organization_row", LiteLLM_OrganizationTable, - DEFAULT_IN_MEMORY_TTL, + management_ttl, ) if refs.project_id is not None: yield _CacheEntry(f"project_id:{refs.project_id}", "project_row", LiteLLM_ProjectTableCachedObj, management_ttl) diff --git a/tests/test_litellm/proxy/auth/test_auth_object_prefetch.py b/tests/test_litellm/proxy/auth/test_auth_object_prefetch.py index 0fd0dda3017..58761e270a0 100644 --- a/tests/test_litellm/proxy/auth/test_auth_object_prefetch.py +++ b/tests/test_litellm/proxy/auth/test_auth_object_prefetch.py @@ -181,8 +181,8 @@ async def test_cold_regime_is_one_mget_one_query_and_the_getters_never_touch_io_ assert sets == sorted( [ f"SET {TEAM_ID}_{USER_ID} ttl=5", - f"SET org_id:{ORG_ID} ttl=5", - f"SET org_id:{ORG_ID}:with_budget ttl=5", + f"SET org_id:{ORG_ID} ttl=60", + f"SET org_id:{ORG_ID}:with_budget ttl=60", f"SET {USER_ID} ttl=60", f"SET team_id:{TEAM_ID} ttl=60", f"SET team_membership:{USER_ID}:{TEAM_ID} ttl=None", From e8f08d045ea974a0a7bd35305d777d05be2c034b Mon Sep 17 00:00:00 2001 From: onatozmenn Date: Mon, 14 Sep 2026 01:28:46 +0300 Subject: [PATCH 11/12] ci: retry ai-gateway image build after Docker Hub oauth token reset flake From 1eb315c02a5dc8f098a0156c77c86bea179db09b Mon Sep 17 00:00:00 2001 From: onatozmenn Date: Fri, 25 Sep 2026 15:04:32 +0300 Subject: [PATCH 12/12] \fix(lint): apply ruff format to operations.py" --- litellm/proxy/_experimental/mcp_server/operations.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/operations.py b/litellm/proxy/_experimental/mcp_server/operations.py index d0d1964c8b3..c101eb3b9cd 100644 --- a/litellm/proxy/_experimental/mcp_server/operations.py +++ b/litellm/proxy/_experimental/mcp_server/operations.py @@ -1017,11 +1017,11 @@ async def _get_tools_from_mcp_servers( "call_type": CallTypes.list_mcp_tools.value, "litellm_call_id": list_tools_call_id, "litellm_trace_id": effective_litellm_trace_id, - "metadata": { - "spend_logs_metadata": spend_logs_metadata, - "headers": logging_safe_mcp_headers(raw_headers), - **({"tags": effective_request_tags} if effective_request_tags else {}), - }, + "metadata": { + "spend_logs_metadata": spend_logs_metadata, + "headers": logging_safe_mcp_headers(raw_headers), + **({"tags": effective_request_tags} if effective_request_tags else {}), + }, # Provide a small input payload for standard logging "input": [ {