mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
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:
parent
7ec47a09c3
commit
1da68c1ada
3 changed files with 161 additions and 9 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue