mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
feat: authorize provider-native MCP requests
This commit is contained in:
parent
f1bb8d8ddd
commit
18cd30435c
7 changed files with 735 additions and 14 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# Provider-executed MCP registration
|
||||
|
||||
Status: registration API, YAML registration, execution isolation, and the explicit provider input model are approved and pushed through `a44cd0949e`. Permission integration is reviewed and approved. Native-request enforcement is next
|
||||
Status: registration and permission integration are approved and pushed through `f1bb8d8ddd`. Native request authorization is implemented and approved for commit and push
|
||||
|
||||
## Implementation plan
|
||||
|
||||
|
|
@ -11,22 +11,54 @@ Work through these layers in order on `litellm_provider_mcp_registration`. Updat
|
|||
| 1 | Settle registration fields, values, storage, and validation | Approved |
|
||||
| 2 | Implement persistence and execution-mode isolation before enabling provider registration | Database, isolation, and admin API committed; YAML registration and provider input model reviewed and approved |
|
||||
| 3 | Management UI/API, stored tool options, and existing key/team/toolset selectors | Scoped retrieval, assignment, and stored tool selectors implemented and approved. Provider registration form remains separate |
|
||||
| 4 | Authorize effective OpenAI Responses and Anthropic Messages MCP attachments through existing MCP permissions | Pending |
|
||||
| 5 | Live provider tests, streaming admission tests, UI tests, and gateway regressions | Pending |
|
||||
| 4 | Authorize effective OpenAI Responses and Anthropic Messages MCP attachments through existing MCP permissions | Implemented and approved |
|
||||
| 5 | Live provider tests, streaming admission tests, UI tests, and gateway regressions | Request admission and gateway regressions verified. Real OpenAI JSON/streaming passed. Real Anthropic calls blocked by invalid QA credential. Earlier browser QA remains incomplete |
|
||||
|
||||
The next phase enforces native attachments before provider dispatch using the completed permission integration. Preserve the current sources of truth and lookup paths. Object permissions remain database-backed and use the existing auth caches. Key assignment keeps its database lookup and gains recognition of provider-only YAML records in its config fallback. Scoped lists keep their existing permission calculation and hydrate authorized provider IDs from the already-loaded registration inventory. Provider tool selectors use stored allowed tools and descriptions. Gateway lookup, discovery, and execution retain their existing paths
|
||||
Request authorization enforces native attachments before provider dispatch using the completed permission integration. Preserve the current sources of truth and lookup paths. Object permissions remain database-backed and use the existing auth caches. Key assignment keeps its database lookup and gains recognition of provider-only YAML records in its config fallback. Scoped lists keep their existing permission calculation and hydrate authorized provider IDs from the already-loaded registration inventory. Provider tool selectors use stored allowed tools and descriptions. Gateway lookup, discovery, and execution retain their existing paths
|
||||
|
||||
Reuse the existing server/tool grant resolution functions, groups, toolsets, and explicit denies. The user accepted today's permission combination and error-handling semantics for v1, including existing inheritance and additive access-group behavior. Do not introduce a stricter policy or broadly replace auth lookups in this feature. Apply the provider registration's allowed-tools ceiling to the resolved permission scope. Native admission must resolve the canonical URL, require provider execution mode, and validate a finite requested tool scope after effective request configuration is assembled. Cover both native APIs and streaming, with denied requests producing no provider dispatch. Provider MCP discovery/execution stays outside LiteLLM
|
||||
|
||||
V1 supports URL-based OpenAI Responses MCP and both Anthropic Messages connector formats. Request authorization applies to streaming and non-streaming calls before provider dispatch. Reuse today's key/team permission semantics, bounded by the registered server's allowed tools. Existing applicable permission ceilings and explicit denies continue to apply
|
||||
|
||||
Response/SSE inspection, per-invocation argument policy inside a provider's tool loop, automatic MCP discovery for provider registrations, local MCP references, provider connector IDs, and additional API bridges are outside v1
|
||||
Chat Completions entry routes, response/SSE inspection, per-invocation argument policy inside a provider's tool loop, automatic MCP discovery for provider registrations, local MCP references, provider connector IDs, and additional API bridges are outside v1
|
||||
|
||||
The registry design review is deferred by agreement on 2026-11-02. This batch retains the executable registry and separate provider registration inventory. A later revision may publish both modes through one registry and derive executable views at gateway boundaries. No registry consolidation is included in this approval
|
||||
|
||||
## Current review acceptance criteria
|
||||
## Request authorization review
|
||||
|
||||
This batch connects provider registrations to existing permission assignment, scoped management retrieval, and key/team/toolset tool choices. It changes no database schema or permission-combination rules. Native LLM request admission remains the next batch. The provider create/edit form, response inspection, and billing attribution remain separate work
|
||||
This batch adds first-class request authorization. It changes no registration schema, permission-combination policy, database lookup owner, or management inventory design. The admission logic lives in `litellm/proxy/mcp_server/auth/provider_mcp.py` and calls the existing server and tool permission resolvers
|
||||
|
||||
Control flow
|
||||
|
||||
1. The shared deployment pre-call function runs after router/model parameters and deployment callbacks are assembled and before response cache lookup. It forwards the effective request and proxy-owned auth metadata through `authorize_provider_mcp_request`
|
||||
2. The request adapter overlays `extra_body` and extracts the existing authenticated `UserAPIKeyAuth`. Ordinary SDK calls without proxy identity retain their behavior
|
||||
3. The admission module classifies OpenAI MCP tools through the same gateway classifier used for execution. Gateway references keep their existing authorization path. Native references and Anthropic connectors become URL plus finite tool-scope attachments
|
||||
4. Within the existing catalog operation, canonical URLs resolve only against provider registrations. `get_allowed_mcp_servers` resolves server grants and `get_allowed_tools_for_server` resolves key/team/toolset/group tool scope. The registered `allowed_tools` bounds that result. A mismatch returns 403 without rewriting the request
|
||||
5. The async Responses and Messages HTTP handlers run the same check on their final transformed body before signing or sending it. This catches attachments added after deployment admission. At this stage any remaining MCP URL is provider-bound, so it cannot escape admission through the gateway shortcut
|
||||
|
||||
There are two admission boundaries for distinct reasons. The deployment boundary prevents revoked MCP access from reading a cached response. The final-body boundary checks what will actually leave LiteLLM after transformations and later hooks. Both use the same policy implementation, and neither discovers or calls MCP tools
|
||||
|
||||
Registered provider URLs take precedence over the existing hostname-blind `/mcp/<name>` gateway shortcut. Other gateway shortcut behavior is preserved. Explicit `litellm_proxy` references remain gateway requests
|
||||
|
||||
| Attachment format | Required bounded scope |
|
||||
| --- | --- |
|
||||
| OpenAI Responses | `allowed_tools` list or `allowed_tools.tool_names`. A read-only filter alone is unbounded |
|
||||
| Anthropic legacy connector | Nonempty `tool_configuration.allowed_tools` when enabled, or explicit `enabled=false` |
|
||||
| Anthropic MCP toolset | `default_config.enabled=false`, with named tools explicitly enabled in `configs` |
|
||||
|
||||
Unknown URLs, gateway-only registrations used as native endpoints, unauthorized servers/tools, ambiguous connector/toolset declarations, and unbounded scope reject. Provider connector IDs are unsupported in v1 and reject even when paired with a registered URL. Empty registration tools remain a deny-all tool ceiling. Local references, response inspection, SSE event authorization, and billing attribution remain outside this batch
|
||||
|
||||
Verification covers unchanged allowed payloads, canonical host/default-port equivalence, URL/path/query changes, current key/team semantics, denied server and tool grants, both Anthropic formats, config and database registrations, model-configured attachments, `extra_body`, JSON and streaming admission, registration revocation, cached responses, late hook changes, gateway routing, and SDK compatibility
|
||||
|
||||
The before/after proxy and PostgreSQL matrix records 23 denied cases that dispatched on `f1bb8d8ddd` and return 403 with zero provider dispatches on this branch. Two database-registered allowed cases reach the controlled upstream unchanged. Real OpenAI MCP discovery and tool execution succeed in JSON and streaming modes. A separate real OpenAI call with `allowed_tools=[]` emitted no MCP tool calls
|
||||
|
||||
Real Anthropic execution is not verified in this batch. The designated local QA key returns `401 invalid x-api-key`, including on a direct `/v1/models` control. Both Anthropic request shapes have unit coverage, and modern connector admission has live proxy coverage with a controlled upstream. Earlier successful provider execution evidence belongs to the separate usage audit and is not claimed as verification of this diff
|
||||
|
||||
The broader focused suite passed 1,675 tests with one skip. The final 42-case native admission suite passed, including added cache-revocation, execution-mode, and connector-ID regressions. Changed-file type comparison against `f1bb8d8ddd` introduced no diagnostics. Local evidence is under `/Users/mchanshetty/Desktop/code/mcp-gap-matrix/auth-review/` and the before-run evidence is under `auth-before-review/`. This is focused local verification, not a full CI run
|
||||
|
||||
## Permission integration review, approved
|
||||
|
||||
The previous batch connected provider registrations to existing permission assignment, scoped management retrieval, and key/team/toolset tool choices. It changes no database schema or permission-combination rules. Native LLM request admission is covered by the current review above. The provider create/edit form, response inspection, and billing attribution remain separate work
|
||||
|
||||
| Area | Behavior and proof |
|
||||
| --- | --- |
|
||||
|
|
@ -87,7 +119,7 @@ One SSRF test was excluded from the focused pass count. It fails identically on
|
|||
|
||||
The separate usage audit made eight real OpenAI Responses and Anthropic Messages requests, comparing control/native MCP and JSON/streaming. All four MCP cases executed successfully. Client usage, normalized token totals, and model costs matched upstream usage and registry pricing. Native server/tool spend attribution and some Anthropic normalized usage details remain follow-ups. No accounting changes are included in this slice
|
||||
|
||||
## Current invocation routing
|
||||
## Invocation routing before request authorization
|
||||
|
||||
On the Responses path, `type: "mcp"` alone does not tell LiteLLM to connect to the server. An external `server_url` such as `https://custom.example/mcp` stays in the provider-bound tools. On a provider/API supporting native MCP, the provider discovers and calls that endpoint, subject to its own connectivity, authentication, and approval requirements. An unsupported provider/API may reject the attachment. LiteLLM does not automatically become the MCP executor
|
||||
|
||||
|
|
@ -97,7 +129,7 @@ Current main also recognizes HTTP(S) URLs ending in `/mcp/<name>` when the name
|
|||
|
||||
These branches are implemented in [tool_precheck.py](../../litellm/responses/mcp/tool_precheck.py), [split_mcp_tools and gateway name resolution](../../litellm/responses/mcp/litellm_proxy_mcp_handler.py), and [Responses orchestration](../../litellm/responses/main.py). The prior live reproduction demonstrated provider execution of custom URLs under metadata and tool-permission guardrail restrictions. It did not configure registered-server object permissions
|
||||
|
||||
The proposed `execution_mode` constrains which registered resource may be used in each path. Provider registrations must never enter gateway discovery or execution, including through the HTTPS path shortcut or toolset membership. Layer 2 must isolate them before enabling registration. Layer 4 must resolve native attachments by the approved canonical endpoint and prevent path-name collisions from silently selecting a different executor. The existing hostname-blind shortcut needs an explicit compatibility decision during that work
|
||||
The proposed `execution_mode` constrains which registered resource may be used in each path. Provider registrations must never enter gateway discovery or execution, including through the HTTPS path shortcut or toolset membership. Layer 2 must isolate them before enabling registration. Layer 4 must resolve native attachments by the approved canonical endpoint and prevent path-name collisions from silently selecting a different executor. The request authorization review above records the compatibility decision for the hostname-blind shortcut
|
||||
|
||||
## Layer 1 schema proposal
|
||||
|
||||
|
|
|
|||
|
|
@ -2642,6 +2642,9 @@ class BaseLLMHTTPHandler:
|
|||
litellm_params=litellm_params,
|
||||
headers=headers,
|
||||
)
|
||||
from litellm.responses.mcp.request_context import authorize_provider_mcp_request
|
||||
|
||||
await authorize_provider_mcp_request(_JSON_OBJECT.validate_python(request_body), kwargs, provider_bound=True)
|
||||
logging_obj.stream = stream
|
||||
logging_obj.model_call_details.update(request_body)
|
||||
|
||||
|
|
@ -3246,6 +3249,13 @@ class BaseLLMHTTPHandler:
|
|||
extra_body=extra_body, request=data, model=model, litellm_params=litellm_params
|
||||
)
|
||||
)
|
||||
from litellm.responses.mcp.request_context import authorize_provider_mcp_request
|
||||
|
||||
await authorize_provider_mcp_request(
|
||||
data,
|
||||
{"litellm_metadata": litellm_metadata},
|
||||
provider_bound=True,
|
||||
)
|
||||
stream = bool(stream or data.get("stream"))
|
||||
|
||||
# Preserve the OpenAI-style request context (not sent to the provider) for streaming
|
||||
|
|
|
|||
137
litellm/proxy/mcp_server/auth/provider_mcp.py
Normal file
137
litellm/proxy/mcp_server/auth/provider_mcp.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Final, NoReturn
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth
|
||||
from litellm.proxy.mcp_server.auth.user_api_key_auth_mcp import MCPRequestHandler
|
||||
from litellm.proxy.mcp_server.catalog import catalog_operation, global_manager
|
||||
from litellm.responses.mcp.litellm_proxy_mcp_handler import LiteLLM_Proxy_MCP_Handler
|
||||
from litellm.types.mcp_server.provider_registration import canonical_provider_mcp_url
|
||||
|
||||
_OBJECT: Final = TypeAdapter(dict[str, object])
|
||||
_OBJECTS: Final = TypeAdapter(tuple[dict[str, object], ...])
|
||||
_NAMES: Final = TypeAdapter(tuple[str, ...])
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProviderMCPAttachment:
|
||||
url: str
|
||||
tools: frozenset[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MCPAdmissionError:
|
||||
message: str
|
||||
|
||||
|
||||
def _names(value: object) -> frozenset[str] | MCPAdmissionError:
|
||||
try:
|
||||
names: Final = _NAMES.validate_python(value)
|
||||
except ValidationError:
|
||||
return MCPAdmissionError("Provider MCP attachments require an explicit allowed_tools list")
|
||||
if any(not name.strip() or any(char in name for char in "*?[]") for name in names):
|
||||
return MCPAdmissionError("Provider MCP tool names must be exact names without wildcards")
|
||||
return frozenset(names)
|
||||
|
||||
|
||||
def _attachment(url: object, tools: frozenset[str] | MCPAdmissionError) -> ProviderMCPAttachment | MCPAdmissionError:
|
||||
if isinstance(tools, MCPAdmissionError):
|
||||
return tools
|
||||
try:
|
||||
canonical: Final = canonical_provider_mcp_url(url)
|
||||
except ValueError:
|
||||
return MCPAdmissionError("Provider MCP attachments require a registered HTTPS server_url")
|
||||
return ProviderMCPAttachment(canonical, tools)
|
||||
|
||||
|
||||
def _openai_attachment(tool: Mapping[str, object]) -> ProviderMCPAttachment | MCPAdmissionError:
|
||||
if tool.get("connector_id") is not None:
|
||||
return MCPAdmissionError("Provider MCP connector IDs are not supported by URL registrations")
|
||||
allowed: Final = tool.get("allowed_tools")
|
||||
names: Final = _OBJECT.validate_python(allowed).get("tool_names") if isinstance(allowed, Mapping) else allowed
|
||||
return _attachment(tool.get("server_url"), _names(names))
|
||||
|
||||
|
||||
def _anthropic_attachment(
|
||||
server: Mapping[str, object], toolsets: tuple[Mapping[str, object], ...]
|
||||
) -> ProviderMCPAttachment | MCPAdmissionError:
|
||||
matching: Final = tuple(tool for tool in toolsets if tool.get("mcp_server_name") == server.get("name"))
|
||||
if server.get("type") != "url" or not isinstance(server.get("name"), str):
|
||||
return MCPAdmissionError("Provider MCP servers must have type=url and a name")
|
||||
if len(matching) > 1 or (matching and server.get("tool_configuration") is not None):
|
||||
return MCPAdmissionError("Provider MCP servers require one unambiguous tool configuration")
|
||||
if matching:
|
||||
toolset: Final = matching[0]
|
||||
default: Final = _OBJECT.validate_python(toolset.get("default_config") or {})
|
||||
if default.get("enabled") is not False:
|
||||
return MCPAdmissionError("Provider MCP toolsets require default_config.enabled=false")
|
||||
configs: Final = TypeAdapter(dict[str, dict[str, object]]).validate_python(toolset.get("configs") or {})
|
||||
if any("enabled" in config and type(config["enabled"]) is not bool for config in configs.values()):
|
||||
return MCPAdmissionError("Provider MCP tool enabled settings must be booleans")
|
||||
enabled: Final = tuple(name for name, config in configs.items() if config.get("enabled") is True)
|
||||
return _attachment(server.get("url"), _names(enabled))
|
||||
config: Final = _OBJECT.validate_python(server.get("tool_configuration") or {})
|
||||
if config.get("enabled") is False:
|
||||
return _attachment(server.get("url"), frozenset())
|
||||
names: Final = _names(config.get("allowed_tools"))
|
||||
if names == frozenset():
|
||||
return MCPAdmissionError("Enabled legacy MCP connectors require a nonempty allowed_tools list")
|
||||
return _attachment(server.get("url"), names)
|
||||
|
||||
|
||||
async def _attachments(
|
||||
body: Mapping[str, object], *, provider_bound: bool
|
||||
) -> tuple[ProviderMCPAttachment | MCPAdmissionError, ...]:
|
||||
tools: Final = _OBJECTS.validate_python(body.get("tools") or ())
|
||||
mcp_tools: Final = tuple(tool for tool in tools if tool.get("type") == "mcp")
|
||||
gateway: Final = (
|
||||
(False,) * len(mcp_tools)
|
||||
if provider_bound
|
||||
else await LiteLLM_Proxy_MCP_Handler.routes_through_gateway(mcp_tools)
|
||||
)
|
||||
openai: Final = tuple(_openai_attachment(tool) for tool, routed in zip(mcp_tools, gateway) if not routed)
|
||||
servers: Final = _OBJECTS.validate_python(body.get("mcp_servers") or ())
|
||||
toolsets: Final = tuple(tool for tool in tools if tool.get("type") == "mcp_toolset")
|
||||
server_names: Final = tuple(server.get("name") for server in servers)
|
||||
if len(set(server_names)) != len(server_names) or any(
|
||||
tool.get("mcp_server_name") not in server_names for tool in toolsets
|
||||
):
|
||||
return (MCPAdmissionError("Provider MCP server names must be unique and toolsets must reference them"),)
|
||||
return (*openai, *(_anthropic_attachment(server, toolsets) for server in servers))
|
||||
|
||||
|
||||
def _raise_denied(error: MCPAdmissionError) -> NoReturn:
|
||||
raise ProxyException(
|
||||
message=error.message,
|
||||
type=ProxyErrorTypes.tool_access_denied,
|
||||
param="tools",
|
||||
code=403,
|
||||
)
|
||||
|
||||
|
||||
@catalog_operation(global_manager)
|
||||
async def authorize_provider_mcp(
|
||||
body: Mapping[str, object], auth: UserAPIKeyAuth, *, provider_bound: bool = False
|
||||
) -> None:
|
||||
try:
|
||||
attachments: Final = await _attachments(body, provider_bound=provider_bound)
|
||||
except (ValidationError, TypeError):
|
||||
_raise_denied(MCPAdmissionError("Invalid provider MCP tool configuration"))
|
||||
if not attachments:
|
||||
return
|
||||
manager: Final = global_manager()
|
||||
registrations: Final = {server.url: server for server in manager.catalog.provider_registrations().values()}
|
||||
allowed_servers: Final = frozenset(await manager.get_allowed_mcp_servers(auth))
|
||||
for attachment in attachments:
|
||||
if isinstance(attachment, MCPAdmissionError):
|
||||
_raise_denied(attachment)
|
||||
registration = registrations.get(attachment.url)
|
||||
if registration is None or registration.server_id not in allowed_servers:
|
||||
_raise_denied(MCPAdmissionError("Provider MCP server is not registered or not permitted for this key/team"))
|
||||
permitted = await MCPRequestHandler.get_allowed_tools_for_server(registration.server_id, auth)
|
||||
ceiling = frozenset(registration.allowed_tools or ())
|
||||
allowed = ceiling if permitted is None else ceiling.intersection(permitted)
|
||||
if not attachment.tools <= allowed:
|
||||
_raise_denied(MCPAdmissionError("Provider MCP tools exceed the registered server or key/team permissions"))
|
||||
|
|
@ -117,6 +117,15 @@ async def _served_mcp_path_names(
|
|||
return await served_names(names) if names else frozenset[str]()
|
||||
|
||||
|
||||
def _provider_url(tool: Mapping[str, object]) -> str | None:
|
||||
from litellm.types.mcp_server.provider_registration import canonical_provider_mcp_url
|
||||
|
||||
try:
|
||||
return canonical_provider_mcp_url(tool.get("server_url"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class LiteLLM_Proxy_MCP_Handler:
|
||||
"""
|
||||
Helper class with static methods for MCP integration with Responses API.
|
||||
|
|
@ -159,13 +168,15 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
served_names: Callable[[Collection[str]], Awaitable[frozenset[str]]] = _gateway_served_names,
|
||||
) -> SplitTools:
|
||||
items: Final = tuple(tools or ())
|
||||
served: Final = await _served_mcp_path_names(items, served_names)
|
||||
if not any(proxy_path_mcp_name(tool) is not None for tool in items):
|
||||
return LiteLLM_Proxy_MCP_Handler._parse_mcp_tools(items)
|
||||
routed: Final = await LiteLLM_Proxy_MCP_Handler.routes_through_gateway(items, served_names)
|
||||
return LiteLLM_Proxy_MCP_Handler._parse_mcp_tools(
|
||||
[
|
||||
{**tool, "server_url": f"{LITELLM_PROXY_MCP_SERVER_URL_PREFIX}{name}"}
|
||||
if (name := proxy_path_mcp_name(tool)) in served
|
||||
{**tool, "server_url": f"{LITELLM_PROXY_MCP_SERVER_URL_PREFIX}{proxy_path_mcp_name(tool)}"}
|
||||
if through_gateway and not names_gateway_explicitly(tool)
|
||||
else tool
|
||||
for tool in items
|
||||
for tool, through_gateway in zip(items, routed)
|
||||
]
|
||||
)
|
||||
|
||||
|
|
@ -179,7 +190,16 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
) -> tuple[bool, ...]:
|
||||
items: Final = tuple(tools or ())
|
||||
served: Final = await _served_mcp_path_names(items, served_names)
|
||||
return tuple(names_gateway_explicitly(tool) or proxy_path_mcp_name(tool) in served for tool in items)
|
||||
if not served:
|
||||
return tuple(map(names_gateway_explicitly, items))
|
||||
provider_urls: Final = frozenset(
|
||||
server.url for server in global_manager().catalog.provider_registrations().values()
|
||||
)
|
||||
return tuple(
|
||||
names_gateway_explicitly(tool)
|
||||
or (proxy_path_mcp_name(tool) in served and _provider_url(tool) not in provider_urls)
|
||||
for tool in items
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _apply_toolset_permissions(
|
||||
|
|
|
|||
|
|
@ -27,6 +27,38 @@ class _AuthCarryingMetadata(TypedDict):
|
|||
user_api_key_auth: ReadOnly[NotRequired["UserAPIKeyAuth | None"]]
|
||||
|
||||
|
||||
async def authorize_provider_mcp_request(
|
||||
body: Mapping[str, object], context: Mapping[str, object], *, provider_bound: bool = False
|
||||
) -> None:
|
||||
buckets: Final = tuple(
|
||||
TypeAdapter(dict[str, object]).validate_python(context[key])
|
||||
for key in ("litellm_metadata", "metadata")
|
||||
if isinstance(context.get(key), Mapping)
|
||||
)
|
||||
auth: Final = next((bucket["user_api_key_auth"] for bucket in buckets if "user_api_key_auth" in bucket), None)
|
||||
if auth is None:
|
||||
return
|
||||
extra: Final = body.get("extra_body")
|
||||
effective: Final = (
|
||||
{**body, **TypeAdapter(dict[str, object]).validate_python(extra)} if isinstance(extra, Mapping) else body
|
||||
)
|
||||
tools: Final = effective.get("tools")
|
||||
has_native_tools: Final = isinstance(tools, (list, tuple)) and any(
|
||||
isinstance(tool, Mapping)
|
||||
and TypeAdapter(dict[str, object]).validate_python(tool).get("type") in ("mcp", "mcp_toolset")
|
||||
for tool in TypeAdapter(tuple[object, ...]).validate_python(tools)
|
||||
)
|
||||
if not has_native_tools and not effective.get("mcp_servers"):
|
||||
return
|
||||
from litellm.types.proxy.auth.user_api_key_auth import UserAPIKeyAuth
|
||||
|
||||
if not isinstance(auth, UserAPIKeyAuth):
|
||||
return
|
||||
from litellm.proxy.mcp_server.auth.provider_mcp import authorize_provider_mcp
|
||||
|
||||
await authorize_provider_mcp(effective, auth, provider_bound=provider_bound)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MCPRequestContext:
|
||||
"""Everything a gateway handler must forward to MCP tool listing and execution."""
|
||||
|
|
|
|||
|
|
@ -1792,6 +1792,10 @@ async def async_pre_call_deployment_hook(kwargs: Mapping[str, object], call_type
|
|||
if result is not None:
|
||||
modified_kwargs = result
|
||||
|
||||
if call_type in (CallTypes.aresponses.value, CallTypes.anthropic_messages.value):
|
||||
from litellm.responses.mcp.request_context import authorize_provider_mcp_request
|
||||
|
||||
await authorize_provider_mcp_request(TypeAdapter(dict[str, object]).validate_python(modified_kwargs), kwargs)
|
||||
return modified_kwargs
|
||||
|
||||
|
||||
|
|
|
|||
486
tests/unit/proxy/mcp_server/auth/test_provider_mcp.py
Normal file
486
tests/unit/proxy/mcp_server/auth/test_provider_mcp.py
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from copy import deepcopy
|
||||
from types import SimpleNamespace
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
import litellm
|
||||
from litellm.caching.caching import Cache
|
||||
from litellm.caching.caching_handler import _PENDING_CACHE_WRITES
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, LiteLLM_TeamTable, ProxyException, UserAPIKeyAuth
|
||||
from litellm.proxy.mcp_server import mcp_server_manager
|
||||
from litellm.proxy.mcp_server.auth.provider_mcp import authorize_provider_mcp
|
||||
from litellm.responses.mcp.litellm_proxy_mcp_handler import LiteLLM_Proxy_MCP_Handler
|
||||
from litellm.responses.mcp.request_context import authorize_provider_mcp_request
|
||||
from litellm.types.mcp import MCPTransport
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
from litellm.utils import async_pre_call_deployment_hook
|
||||
|
||||
|
||||
def _openai(tools: object = ("search",), url: str = "https://native.example/mcp") -> dict[str, object]:
|
||||
return {"tools": [{"type": "mcp", "server_label": "arbitrary", "server_url": url, "allowed_tools": tools}]}
|
||||
|
||||
|
||||
def _anthropic(legacy: bool = False) -> dict[str, object]:
|
||||
return {
|
||||
"mcp_servers": [
|
||||
{
|
||||
"type": "url",
|
||||
"name": "docs",
|
||||
"url": "https://native.example/mcp",
|
||||
**({"tool_configuration": {"allowed_tools": ["search"]}} if legacy else {}),
|
||||
}
|
||||
],
|
||||
**(
|
||||
{}
|
||||
if legacy
|
||||
else {
|
||||
"tools": [
|
||||
{
|
||||
"type": "mcp_toolset",
|
||||
"mcp_server_name": "docs",
|
||||
"default_config": {"enabled": False},
|
||||
"configs": {"search": {"enabled": True}, "delete": {"enabled": False}},
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def native_auth(monkeypatch: pytest.MonkeyPatch) -> UserAPIKeyAuth:
|
||||
manager: Final = mcp_server_manager.MCPServerManager()
|
||||
cache: Final = DualCache()
|
||||
monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager)
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", None)
|
||||
monkeypatch.setattr(proxy_server, "user_api_key_cache", cache)
|
||||
await manager.load_servers_from_config(
|
||||
{
|
||||
"docs": {
|
||||
"server_id": "native-id",
|
||||
"alias": "native-alias",
|
||||
"execution_mode": "provider",
|
||||
"url": "https://native.example/mcp",
|
||||
"allowed_tools": ["search", "fetch"],
|
||||
}
|
||||
}
|
||||
)
|
||||
await cache.async_set_cache(
|
||||
key="team_id:team",
|
||||
value=LiteLLM_TeamTable(
|
||||
team_id="team",
|
||||
object_permission=LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="team-grant",
|
||||
mcp_servers=["docs"],
|
||||
mcp_tool_permissions={"docs": ["search"]},
|
||||
),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", SimpleNamespace())
|
||||
monkeypatch.setattr(proxy_server, "general_settings", {"supported_db_objects": []})
|
||||
return UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
team_id="team",
|
||||
object_permission=LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="key-grant",
|
||||
mcp_servers=["native-alias"],
|
||||
mcp_tool_permissions={"native-alias": ["search", "fetch"]},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body", [_openai(), _openai({"tool_names": ["search"], "read_only": True}), _anthropic(), _anthropic(True)]
|
||||
)
|
||||
async def test_allowed_native_attachment_is_unchanged(body: Mapping[str, object], native_auth: UserAPIKeyAuth) -> None:
|
||||
before: Final = deepcopy(body)
|
||||
await authorize_provider_mcp(body, native_auth)
|
||||
assert body == before
|
||||
assert mcp_server_manager.global_mcp_server_manager.get_registry() == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
_openai(["fetch"]),
|
||||
_openai(["delete"]),
|
||||
_openai(None),
|
||||
_openai({"read_only": True}),
|
||||
_openai(["*"]),
|
||||
_openai(["search"], "https://unregistered.example/mcp"),
|
||||
_openai(["search"], "http://native.example/mcp"),
|
||||
_openai(["search"], "https://native.example/mcp?endpoint=other"),
|
||||
_openai(["search"], "https://native.example/mcp/"),
|
||||
_openai(["search"], "https://native.example@other.example/mcp"),
|
||||
{"mcp_servers": [{"type": "url", "name": "docs", "url": "https://native.example/mcp"}]},
|
||||
{
|
||||
"mcp_servers": [
|
||||
{
|
||||
"type": "url",
|
||||
"name": "docs",
|
||||
"url": "https://native.example/mcp",
|
||||
"tool_configuration": {"allowed_tools": []},
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
**_anthropic(),
|
||||
"tools": [{"type": "mcp_toolset", "mcp_server_name": "docs", "configs": {"search": {"enabled": True}}}],
|
||||
},
|
||||
{
|
||||
**_anthropic(),
|
||||
"tools": [{"type": "mcp_toolset", "mcp_server_name": "missing", "default_config": {"enabled": False}}],
|
||||
},
|
||||
{"tools": [{"type": "mcp", "connector_id": "connector_example", "allowed_tools": ["search"]}]},
|
||||
],
|
||||
)
|
||||
async def test_native_attachment_denies_unbounded_or_unauthorized_scope(
|
||||
body: Mapping[str, object], native_auth: UserAPIKeyAuth
|
||||
) -> None:
|
||||
with pytest.raises(ProxyException) as denied:
|
||||
await authorize_provider_mcp(body, native_auth)
|
||||
assert denied.value.code == "403"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("servers", [["no-mcp-servers"], ["unrelated"]])
|
||||
async def test_server_denial_cannot_be_bypassed_by_tool_name(servers: list[str], native_auth: UserAPIKeyAuth) -> None:
|
||||
auth: Final = native_auth.model_copy(
|
||||
update={
|
||||
"object_permission": LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="denied",
|
||||
mcp_servers=servers,
|
||||
)
|
||||
}
|
||||
)
|
||||
with pytest.raises(ProxyException) as denied:
|
||||
await authorize_provider_mcp(_openai(), auth)
|
||||
assert denied.value.code == "403"
|
||||
|
||||
|
||||
async def test_canonical_host_and_default_port_match(native_auth: UserAPIKeyAuth) -> None:
|
||||
body: Final = _openai(["search"], "https://NATIVE.example:443/mcp")
|
||||
await authorize_provider_mcp(body, native_auth)
|
||||
assert body == _openai(["search"], "https://NATIVE.example:443/mcp")
|
||||
|
||||
|
||||
async def test_extra_body_cannot_replace_authorized_attachment(native_auth: UserAPIKeyAuth) -> None:
|
||||
kwargs: Final = {
|
||||
**_openai(),
|
||||
"extra_body": _openai(["delete"]),
|
||||
"litellm_metadata": {"user_api_key_auth": native_auth},
|
||||
}
|
||||
with pytest.raises(ProxyException) as denied:
|
||||
await async_pre_call_deployment_hook(kwargs, "aresponses")
|
||||
assert denied.value.code == "403"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider_bound", [False, True])
|
||||
async def test_registration_revocation_is_seen_on_next_admission(
|
||||
native_auth: UserAPIKeyAuth, provider_bound: bool
|
||||
) -> None:
|
||||
await authorize_provider_mcp(_openai(), native_auth, provider_bound=provider_bound)
|
||||
await mcp_server_manager.global_mcp_server_manager.load_servers_from_config(
|
||||
{
|
||||
"docs": {
|
||||
"server_id": "native-id",
|
||||
"execution_mode": "provider",
|
||||
"url": "https://native.example/mcp",
|
||||
"allowed_tools": [],
|
||||
}
|
||||
}
|
||||
)
|
||||
with pytest.raises(ProxyException) as denied:
|
||||
await authorize_provider_mcp(_openai(), native_auth, provider_bound=provider_bound)
|
||||
assert denied.value.code == "403"
|
||||
|
||||
|
||||
async def test_sdk_without_proxy_identity_preserves_native_passthrough(native_auth: UserAPIKeyAuth) -> None:
|
||||
body: Final = _openai(["anything"], "https://unregistered.example/mcp")
|
||||
await authorize_provider_mcp_request(body, {})
|
||||
assert body == _openai(["anything"], "https://unregistered.example/mcp")
|
||||
|
||||
|
||||
async def test_provider_url_wins_over_gateway_path_shortcut(native_auth: UserAPIKeyAuth) -> None:
|
||||
manager: Final = mcp_server_manager.global_mcp_server_manager
|
||||
await manager.load_servers_from_config(
|
||||
{
|
||||
"native": {
|
||||
"server_id": "provider-path",
|
||||
"execution_mode": "provider",
|
||||
"url": "https://native.example/mcp/gateway",
|
||||
"allowed_tools": ["search"],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async def gateway_names(names: object) -> frozenset[str]:
|
||||
return frozenset({"gateway"})
|
||||
|
||||
tools: Final = [{"type": "mcp", "server_url": "https://native.example/mcp/gateway", "allowed_tools": ["search"]}]
|
||||
assert await LiteLLM_Proxy_MCP_Handler.routes_through_gateway(tools, gateway_names) == (False,)
|
||||
assert await LiteLLM_Proxy_MCP_Handler.split_mcp_tools(tools, gateway_names) == ([], tools)
|
||||
|
||||
|
||||
async def test_explicit_gateway_references_retain_existing_path(native_auth: UserAPIKeyAuth) -> None:
|
||||
tools: Final = [{"type": "mcp", "server_url": "litellm_proxy/mcp/gateway"}]
|
||||
await authorize_provider_mcp({"tools": tools}, native_auth)
|
||||
assert await LiteLLM_Proxy_MCP_Handler.split_mcp_tools(tools) == (tools, [])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("api", ["aresponses", "anthropic_messages"])
|
||||
@pytest.mark.parametrize("stream", [False, True])
|
||||
@pytest.mark.parametrize("configured", [False, True])
|
||||
async def test_denied_native_request_never_reaches_provider(
|
||||
native_auth: UserAPIKeyAuth,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
api: str,
|
||||
stream: bool,
|
||||
configured: bool,
|
||||
) -> None:
|
||||
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(500)
|
||||
attachment: Final = (
|
||||
_openai(["delete"])
|
||||
if api == "aresponses"
|
||||
else {
|
||||
"mcp_servers": [
|
||||
{
|
||||
"type": "url",
|
||||
"name": "docs",
|
||||
"url": "https://unregistered.example/mcp",
|
||||
"tool_configuration": {"allowed_tools": ["search"]},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "native-model",
|
||||
"litellm_params": {
|
||||
"model": "openai/gpt-5.6-luna" if api == "aresponses" else "anthropic/claude-haiku-5-5",
|
||||
"api_key": "test-provider-key",
|
||||
"api_base": "https://native-provider.example/v1",
|
||||
**(attachment if configured else {}),
|
||||
},
|
||||
}
|
||||
],
|
||||
num_retries=0,
|
||||
)
|
||||
request: Final = {
|
||||
"model": "native-model",
|
||||
"stream": stream,
|
||||
"litellm_metadata": {"user_api_key_auth": native_auth},
|
||||
**({} if configured else attachment),
|
||||
}
|
||||
with pytest.raises(Exception) as denied:
|
||||
if api == "aresponses":
|
||||
await router.aresponses(input="Use the tool", **request)
|
||||
else:
|
||||
await router.anthropic_messages(
|
||||
messages=[{"role": "user", "content": "Use the tool"}], max_tokens=32, **request
|
||||
)
|
||||
assert str(getattr(denied.value, "status_code", getattr(denied.value, "code", ""))) == "403"
|
||||
assert upstream.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("api", ["aresponses", "anthropic_messages"])
|
||||
async def test_allowed_native_request_reaches_provider_unchanged(
|
||||
native_auth: UserAPIKeyAuth,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
api: str,
|
||||
) -> None:
|
||||
import json
|
||||
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
attachment: Final = _openai(["search"]) if api == "aresponses" else _anthropic()
|
||||
response: Final = (
|
||||
{
|
||||
"id": "resp_native",
|
||||
"object": "response",
|
||||
"created_at": 1,
|
||||
"model": "gpt-5.6-luna",
|
||||
"status": "completed",
|
||||
"output": [],
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
|
||||
}
|
||||
if api == "aresponses"
|
||||
else {
|
||||
"id": "msg_native",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-haiku-5-5",
|
||||
"content": [{"type": "text", "text": "done"}],
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1},
|
||||
}
|
||||
)
|
||||
upstream: Final = respx_mock.post(url__regex=r"https://native-provider.example/.*").respond(200, json=response)
|
||||
request: Final = {
|
||||
"api_key": "test-provider-key",
|
||||
"api_base": "https://native-provider.example/v1",
|
||||
"litellm_metadata": {"user_api_key_auth": native_auth},
|
||||
**attachment,
|
||||
}
|
||||
if api == "aresponses":
|
||||
await litellm.aresponses(model="openai/gpt-5.6-luna", input="Use the tool", **request)
|
||||
else:
|
||||
await litellm.anthropic_messages(
|
||||
model="anthropic/claude-haiku-5-5",
|
||||
messages=[{"role": "user", "content": "Use the tool"}],
|
||||
max_tokens=32,
|
||||
**request,
|
||||
)
|
||||
sent: Final = json.loads(upstream.calls.last.request.content)
|
||||
assert {key: sent[key] for key in attachment} == attachment
|
||||
assert upstream.call_count == 1
|
||||
|
||||
|
||||
async def test_cached_response_does_not_bypass_revoked_mcp_permission(
|
||||
native_auth: UserAPIKeyAuth,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "cache", Cache(type="local"))
|
||||
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(
|
||||
200,
|
||||
json={
|
||||
"id": "resp_cached",
|
||||
"object": "response",
|
||||
"created_at": 1,
|
||||
"model": "gpt-5.6-luna",
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_cached",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": "tool result", "annotations": []}],
|
||||
}
|
||||
],
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
|
||||
},
|
||||
)
|
||||
request: Final = {
|
||||
"model": "openai/gpt-5.6-luna",
|
||||
"input": "Use the tool",
|
||||
"caching": True,
|
||||
"api_key": "test-provider-key",
|
||||
"api_base": "https://native-provider.example/v1",
|
||||
**_openai(["search"]),
|
||||
}
|
||||
await litellm.aresponses(**request, litellm_metadata={"user_api_key_auth": native_auth})
|
||||
await asyncio.gather(*tuple(_PENDING_CACHE_WRITES))
|
||||
await litellm.aresponses(**request, litellm_metadata={"user_api_key_auth": native_auth})
|
||||
assert upstream.call_count == 1
|
||||
denied_auth: Final = native_auth.model_copy(
|
||||
update={
|
||||
"object_permission": LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="revoked",
|
||||
mcp_servers=["no-mcp-servers"],
|
||||
)
|
||||
}
|
||||
)
|
||||
with pytest.raises(ProxyException) as denied:
|
||||
await litellm.aresponses(**request, litellm_metadata={"user_api_key_auth": denied_auth})
|
||||
assert denied.value.code == "403"
|
||||
assert upstream.call_count == 1
|
||||
|
||||
|
||||
class _LateMCPAttachment(CustomLogger):
|
||||
async def async_pre_request_hook(self, model: str, messages: list[object], kwargs: dict[str, object]) -> dict:
|
||||
return {
|
||||
**kwargs,
|
||||
"mcp_servers": [
|
||||
{
|
||||
"type": "url",
|
||||
"name": "docs",
|
||||
"url": "https://unregistered.example/mcp",
|
||||
"tool_configuration": {"allowed_tools": ["search"]},
|
||||
}
|
||||
],
|
||||
"tools": [],
|
||||
}
|
||||
|
||||
|
||||
async def test_final_body_check_blocks_attachment_added_after_deployment_hook(
|
||||
native_auth: UserAPIKeyAuth,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "callbacks", [_LateMCPAttachment()])
|
||||
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)
|
||||
with pytest.raises(Exception) as denied:
|
||||
await litellm.anthropic_messages(
|
||||
model="anthropic/claude-haiku-5-5",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
max_tokens=32,
|
||||
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"
|
||||
assert upstream.call_count == 0
|
||||
|
||||
|
||||
async def test_gateway_registration_does_not_authorize_provider_execution(native_auth: UserAPIKeyAuth) -> None:
|
||||
manager: Final = mcp_server_manager.global_mcp_server_manager
|
||||
manager.catalog.publish_config_registrations({})
|
||||
manager.config_mcp_servers = {
|
||||
"gateway": MCPServer(
|
||||
server_id="gateway",
|
||||
name="gateway",
|
||||
server_name="gateway",
|
||||
url="https://native.example/mcp",
|
||||
transport=MCPTransport.http,
|
||||
allowed_tools=["search"],
|
||||
)
|
||||
}
|
||||
auth: Final = native_auth.model_copy(
|
||||
update={
|
||||
"team_id": None,
|
||||
"object_permission": LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="gateway-grant",
|
||||
mcp_servers=["gateway"],
|
||||
),
|
||||
}
|
||||
)
|
||||
with pytest.raises(ProxyException) as denied:
|
||||
await authorize_provider_mcp(_openai(), auth)
|
||||
assert denied.value.code == "403"
|
||||
|
||||
|
||||
async def test_connector_id_cannot_override_a_registered_url(native_auth: UserAPIKeyAuth) -> None:
|
||||
with pytest.raises(ProxyException) as denied:
|
||||
await authorize_provider_mcp(
|
||||
{
|
||||
"tools": [
|
||||
{
|
||||
"type": "mcp",
|
||||
"server_url": "https://native.example/mcp",
|
||||
"connector_id": "connector_other",
|
||||
"allowed_tools": ["search"],
|
||||
}
|
||||
]
|
||||
},
|
||||
native_auth,
|
||||
)
|
||||
assert denied.value.code == "403"
|
||||
Loading…
Add table
Reference in a new issue