From 1da68c1ada07dca99985daf16d3cde900ce3b649 Mon Sep 17 00:00:00 2001 From: Mrinal Chanshetty Date: Tue, 3 Nov 2026 20:08:15 -0800 Subject: [PATCH] fix(mcp): check provider MCP tool grants on Claude Platform chat requests Claude Platform on Bedrock runs MCP servers named in a chat request, but chat was only checked when the provider was anthropic, and the shared chat handler had no final-body check. Chat is now checked before dispatch for any deployment whose chat config runs hosted MCP, and the shared handler checks the final body for those configs. --- litellm/llms/custom_httpx/llm_http_handler.py | 53 +++++++++- litellm/utils.py | 18 +++- .../mcp_server/auth/test_provider_mcp.py | 99 +++++++++++++++++-- 3 files changed, 161 insertions(+), 9 deletions(-) diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 12e8e13a2d5..5193787b92e 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -606,6 +606,40 @@ _DECODED_BODY_STALE_HEADERS: Final[frozenset[str]] = frozenset({"content-encodin _OPTIONAL_PARAMS: Final = TypeAdapter(Mapping[str, object]) _JSON_OBJECT: Final = TypeAdapter(dict[str, object]) _JSON_OBJECTS: Final = TypeAdapter(list[dict[str, object]]) + + +async def _admit_hosted_mcp_chat_request( + provider_config: BaseConfig, + data: Mapping[str, object], + litellm_params: Mapping[str, object], + *, + model: str, + custom_llm_provider: str, + api_base: str, +) -> None: + """Check provider-run MCP attachments in the final chat body against the caller's tool grants. + + Only chat configs that forward MCP server URLs to the provider (Claude Platform on Bedrock) + need this; the Anthropic chat handler runs the same check on its own path.""" + from litellm.llms.anthropic.chat.transformation import AnthropicConfig + from litellm.proxy._types import ProxyException + from litellm.responses.mcp.request_context import authorize_provider_mcp_request + + if not (isinstance(provider_config, AnthropicConfig) and provider_config.runs_hosted_mcp()): + return + try: + await authorize_provider_mcp_request( + _JSON_OBJECT.validate_python(data), _JSON_OBJECT.validate_python(litellm_params), provider_bound=True + ) + except ProxyException as error: + raise litellm.PermissionDeniedError( + message=error.message, + model=model, + llm_provider=custom_llm_provider, + response=httpx.Response(403, request=httpx.Request("POST", api_base)), + ) from error + + _DECODED_JSON: Final = TypeAdapter(object) @@ -1015,6 +1049,21 @@ class BaseLLMHTTPHandler: shared_session=shared_session, ) + async def admit_then_dispatch( + data: dict[str, object], # mutable-ok: async_completion takes dict + signed_headers: dict[str, object], # mutable-ok: async_completion takes dict + signed_json_body: bytes | None, + ) -> ModelResponse | CustomStreamWrapper: + await _admit_hosted_mcp_chat_request( + provider_config, + data, + cast("Mapping[str, object]", litellm_params), # cast-ok: completion's litellm_params is a bare dict + model=model, + custom_llm_provider=custom_llm_provider, + api_base=api_base, + ) + return await dispatch_async(data, signed_headers, signed_json_body) + if acompletion is True and provider_config.uses_async_transform_request: async def transform_then_dispatch() -> ModelResponse | CustomStreamWrapper: @@ -1033,7 +1082,7 @@ class BaseLLMHTTPHandler: if isinstance(provider_config, SignsRequestsWithAWS) else asyncio.to_thread(sign_and_log, transformed) ) - return await dispatch_async(*signed_request) + return await admit_then_dispatch(*signed_request) return transform_then_dispatch() @@ -1048,7 +1097,7 @@ class BaseLLMHTTPHandler: ) if acompletion is True: - return dispatch_async(data, signed_headers, signed_json_body) + return admit_then_dispatch(data, signed_headers, signed_json_body) if stream is True: data = self._add_stream_param_to_request_body( diff --git a/litellm/utils.py b/litellm/utils.py index 656c27d2838..a40d3b5fa61 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -1788,6 +1788,21 @@ def check_coroutine(value) -> bool: return get_coroutine_checker().is_async_callable(value) +def _chat_deployment_runs_hosted_mcp(kwargs: Mapping[str, object]) -> bool: + """Whether this chat deployment forwards MCP server URLs for the provider to call (hosted MCP), + as Claude Platform on Bedrock does, so its tool grants must be checked before dispatch.""" + from litellm.llms.anthropic.chat.transformation import AnthropicConfig + + model: Final = kwargs.get("model") + provider: Final = _deployment_provider(kwargs) + if not isinstance(model, str) or provider not in LlmProviders._value2member_map_: + return False + config: Final = ProviderConfigManager.get_provider_chat_config( + model=litellm.get_llm_provider(model=model, custom_llm_provider=provider)[0], provider=LlmProviders(provider) + ) + return isinstance(config, AnthropicConfig) and config.runs_hosted_mcp() + + def _deployment_provider(kwargs: Mapping[str, object]) -> str: model: Final = kwargs.get("model") custom_llm_provider: Final = kwargs.get("custom_llm_provider") @@ -1834,7 +1849,8 @@ async def async_pre_call_deployment_hook(kwargs: Mapping[str, object], call_type modified_kwargs = result if call_type in (CallTypes.aresponses.value, CallTypes.anthropic_messages.value) or ( - call_type == CallTypes.acompletion.value and _deployment_provider(modified_kwargs) == "anthropic" + call_type == CallTypes.acompletion.value + and (_deployment_provider(modified_kwargs) == "anthropic" or _chat_deployment_runs_hosted_mcp(modified_kwargs)) ): from litellm.responses.mcp.request_context import authorize_provider_mcp_request diff --git a/tests/unit/proxy/mcp_server/auth/test_provider_mcp.py b/tests/unit/proxy/mcp_server/auth/test_provider_mcp.py index 3558f62ce7a..2cfc4da7968 100644 --- a/tests/unit/proxy/mcp_server/auth/test_provider_mcp.py +++ b/tests/unit/proxy/mcp_server/auth/test_provider_mcp.py @@ -365,6 +365,86 @@ async def test_denied_native_request_never_reaches_provider( assert upstream.call_count == 0 +CLAUDE_PLATFORM_PARAMS: Final = { + "model": "bedrock/claude_platform/claude-sonnet-4-6", + "api_key": "test-provider-key", + "api_base": "https://native-provider.example/v1/messages", + "workspace_id": "wrkspc_test", + "aws_region_name": "us-west-2", +} +CLAUDE_PLATFORM_REPLY: Final = { + "id": "msg_native", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-6", + "content": [{"type": "text", "text": "done"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1}, +} + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("routed", [False, True]) +async def test_claude_platform_chat_with_ungranted_provider_tools_never_reaches_bedrock( + native_auth: UserAPIKeyAuth, monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter, stream: bool, routed: bool +) -> None: + """Claude Platform on Bedrock runs MCP servers named in a chat request, so chat must be checked + like the Anthropic provider, both before dispatch (routed) and on the final body (direct).""" + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True") + litellm.in_memory_llm_clients_cache.flush_cache() + upstream: Final = respx_mock.post(url__regex=r"https://native-provider.example/.*").respond( + 200, json=CLAUDE_PLATFORM_REPLY + ) + request: Final = { + "messages": [{"role": "user", "content": "Use the tool"}], + "max_tokens": 32, + "stream": stream, + "metadata": {"user_api_key_auth": native_auth}, + **_openai(["delete"]), + } + call: Final = ( + litellm.Router( + model_list=[{"model_name": "platform", "litellm_params": dict(CLAUDE_PLATFORM_PARAMS)}], num_retries=0 + ).acompletion(model="platform", **request) + if routed + else litellm.acompletion(**CLAUDE_PLATFORM_PARAMS, **request) + ) + with pytest.raises((ProxyException, litellm.PermissionDeniedError)) as denied: + await call + assert str(getattr(denied.value, "status_code", getattr(denied.value, "code", ""))) == "403" + assert upstream.call_count == 0 + + +async def test_claude_platform_chat_with_granted_provider_tools_reaches_bedrock_with_its_mcp_server( + native_auth: UserAPIKeyAuth, monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter +) -> None: + import json + + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True") + litellm.in_memory_llm_clients_cache.flush_cache() + upstream: Final = respx_mock.post(url__regex=r"https://native-provider.example/.*").respond( + 200, json=CLAUDE_PLATFORM_REPLY + ) + await litellm.acompletion( + **CLAUDE_PLATFORM_PARAMS, + messages=[{"role": "user", "content": "Use the tool"}], + max_tokens=32, + metadata={"user_api_key_auth": native_auth}, + **_openai(["search"]), + ) + sent: Final = json.loads(upstream.calls.last.request.content) + assert sent["mcp_servers"] == [ + { + "type": "url", + "url": "https://native.example/mcp", + "name": "arbitrary", + "tool_configuration": {"allowed_tools": ["search"]}, + } + ] + + @pytest.mark.parametrize("api", ["aresponses", "anthropic_messages", "acompletion"]) async def test_allowed_native_request_reaches_provider_unchanged( native_auth: UserAPIKeyAuth, @@ -540,7 +620,7 @@ class _LateChatMCPAttachment(CustomLogger): ) -@pytest.mark.parametrize("api", ["anthropic_messages", "acompletion"]) +@pytest.mark.parametrize("api", ["anthropic_messages", "acompletion", "claude_platform_chat"]) @pytest.mark.parametrize("stream", [False, True]) async def test_final_body_check_blocks_attachment_added_after_deployment_hook( native_auth: UserAPIKeyAuth, @@ -549,20 +629,27 @@ async def test_final_body_check_blocks_attachment_added_after_deployment_hook( api: str, stream: bool, ) -> None: - callback: Final = _LateChatMCPAttachment() if api == "acompletion" else _LateMCPAttachment() + callback: Final = _LateChatMCPAttachment() if api in ("acompletion", "claude_platform_chat") else _LateMCPAttachment() monkeypatch.setattr(litellm, "callbacks", [callback]) monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True") upstream: Final = respx_mock.post(url__regex=r"https://native-provider.example/.*").respond(500) - call: Final = litellm.acompletion if api == "acompletion" else litellm.anthropic_messages + call: Final = litellm.anthropic_messages if api == "anthropic_messages" else litellm.acompletion + deployment: Final = ( + CLAUDE_PLATFORM_PARAMS + if api == "claude_platform_chat" + else { + "model": "anthropic/claude-haiku-5-5", + "api_key": "test-provider-key", + "api_base": "https://native-provider.example/v1", + } + ) with pytest.raises((ProxyException, litellm.PermissionDeniedError)) as denied: await call( - model="anthropic/claude-haiku-5-5", + **deployment, messages=[{"role": "user", "content": "hello"}], max_tokens=32, stream=stream, - api_key="test-provider-key", - api_base="https://native-provider.example/v1", litellm_metadata={"user_api_key_auth": native_auth}, ) assert str(getattr(denied.value, "status_code", getattr(denied.value, "code", ""))) == "403"