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.
This commit is contained in:
Mrinal Chanshetty 2026-11-03 20:08:15 -08:00
parent 7ec47a09c3
commit 1da68c1ada
3 changed files with 161 additions and 9 deletions

View file

@ -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(

View file

@ -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

View file

@ -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"