diff --git a/litellm/proxy/_experimental/mcp_server/capabilities.py b/litellm/proxy/_experimental/mcp_server/capabilities.py
index 1ee263633be..e12c2346d1a 100644
--- a/litellm/proxy/_experimental/mcp_server/capabilities.py
+++ b/litellm/proxy/_experimental/mcp_server/capabilities.py
@@ -15,6 +15,8 @@ from litellm.types.mcp import (
MCP_LEGACY_VERSIONS,
MCPAdvertisedVersion,
MCPAdvertisedVersions,
+ MCPDisableableCapability,
+ MCPDisabledCapabilities,
MCPSpecVersion,
MCPTransport,
)
@@ -30,6 +32,12 @@ GATEWAY_OPERATIONS: Final = frozenset(
"resources/templates/list",
}
)
+_CAPABILITY_OPERATIONS: Final[Mapping[MCPDisableableCapability, frozenset[str]]] = MappingProxyType(
+ {
+ "prompts": frozenset({"prompts/list", "prompts/get"}),
+ "resources": frozenset({"resources/list", "resources/read", "resources/templates/list"}),
+ }
+)
@dataclass(frozen=True, slots=True)
@@ -60,6 +68,7 @@ REVISION_SUPPORT: Final[Mapping[str, RevisionSupport]] = MappingProxyType(
_COMPLETED_REVISIONS: Final = tuple(version for version, support in REVISION_SUPPORT.items() if support.completed)
TRANSLATION_PAIRS: Final = frozenset(product(_COMPLETED_REVISIONS, repeat=2))
_ADVERTISED_VERSIONS: Final[TypeAdapter[tuple[MCPAdvertisedVersion, ...]]] = TypeAdapter(MCPAdvertisedVersions)
+_DISABLED_CAPABILITIES: Final[TypeAdapter[MCPDisabledCapabilities]] = TypeAdapter(MCPDisabledCapabilities)
def configured_versions() -> tuple[str, ...]:
@@ -69,6 +78,22 @@ def configured_versions() -> tuple[str, ...]:
return _ADVERTISED_VERSIONS.validate_python(MCP_LEGACY_VERSIONS if configured is None else configured)
+def disabled_capabilities() -> frozenset[MCPDisableableCapability]:
+ from litellm.proxy.proxy_server import general_settings_view
+
+ return frozenset(
+ _DISABLED_CAPABILITIES.validate_python(general_settings_view().get("mcp_disabled_capabilities") or ())
+ )
+
+
+def disabled_operations(disabled: frozenset[MCPDisableableCapability]) -> frozenset[str]:
+ return frozenset[str]().union(*(_CAPABILITY_OPERATIONS[name] for name in disabled))
+
+
+def enabled_gateway_operations(disabled: frozenset[MCPDisableableCapability]) -> frozenset[str]:
+ return GATEWAY_OPERATIONS - disabled_operations(disabled)
+
+
def build_discovery(
*,
configured: tuple[str, ...],
@@ -118,8 +143,13 @@ def build_discovery(
class GatewayVersionPolicy:
- def __init__(self, versions: Callable[[], tuple[str, ...]] = configured_versions) -> None:
+ def __init__(
+ self,
+ versions: Callable[[], tuple[str, ...]] = configured_versions,
+ disabled: Callable[[], frozenset[MCPDisableableCapability]] = disabled_capabilities,
+ ) -> None:
self._versions = versions
+ self._disabled = disabled
async def __call__(self, ctx: ServerRequestContext[object, object], call_next: CallNext) -> HandlerResult:
versions: Final = self._versions()
@@ -143,7 +173,7 @@ class GatewayVersionPolicy:
configured=versions,
revision=initialized.protocol_version,
transport=MCPTransport.http,
- authorized_operations=GATEWAY_OPERATIONS,
+ authorized_operations=enabled_gateway_operations(self._disabled()),
upstream_versions=frozenset(HANDSHAKE_PROTOCOL_VERSIONS),
capabilities=initialized.capabilities,
instructions=initialized.instructions,
diff --git a/litellm/proxy/_experimental/mcp_server/operations.py b/litellm/proxy/_experimental/mcp_server/operations.py
index 578f54fdd68..1cd14e032a3 100644
--- a/litellm/proxy/_experimental/mcp_server/operations.py
+++ b/litellm/proxy/_experimental/mcp_server/operations.py
@@ -64,9 +64,10 @@ from litellm.proxy._experimental.mcp_server.byok_credential_cache import (
get_cached_byok_credential,
)
from litellm.proxy._experimental.mcp_server.capabilities import (
- GATEWAY_OPERATIONS,
build_discovery,
configured_versions,
+ disabled_capabilities,
+ enabled_gateway_operations,
)
from litellm.proxy._experimental.mcp_server.catalog import catalog_operation, global_manager
from litellm.proxy._experimental.mcp_server.contracts import (
@@ -3092,6 +3093,8 @@ async def _execute_list_prompts(
if context.mcp_proxy_mode:
_reject_mcp_proxy_operation()
+ if "prompts" in disabled_capabilities():
+ return ListPromptsResult(prompts=[])
try:
catalog: Final = MCPServerTargetCatalog(global_mcp_server_manager, listing=list_gateway_catalog)
result: Final = await catalog.list(context, ListPromptsRequest(params=params))
@@ -3114,6 +3117,8 @@ async def _execute_get_prompt(
) -> GetPromptResult | InputRequiredResult:
if context.mcp_proxy_mode:
_reject_mcp_proxy_operation()
+ if "prompts" in disabled_capabilities():
+ _reject_disabled_capability("prompts")
(
user_api_key_auth,
mcp_auth_header,
@@ -3152,6 +3157,8 @@ async def _execute_list_resources(
if context.mcp_proxy_mode:
_reject_mcp_proxy_operation()
+ if "resources" in disabled_capabilities():
+ return ListResourcesResult(resources=[])
try:
catalog: Final = MCPServerTargetCatalog(global_mcp_server_manager, listing=list_gateway_catalog)
result: Final = await catalog.list(context, ListResourcesRequest(params=params))
@@ -3180,6 +3187,8 @@ async def _execute_list_resource_templates(
if context.mcp_proxy_mode:
_reject_mcp_proxy_operation()
+ if "resources" in disabled_capabilities():
+ return ListResourceTemplatesResult(resource_templates=[])
try:
catalog: Final = MCPServerTargetCatalog(global_mcp_server_manager, listing=list_gateway_catalog)
result: Final = await catalog.list(context, ListResourceTemplatesRequest(params=params))
@@ -3202,6 +3211,8 @@ async def _execute_read_resource(
) -> ReadResourceResult | InputRequiredResult:
if context.mcp_proxy_mode:
_reject_mcp_proxy_operation()
+ if "resources" in disabled_capabilities():
+ _reject_disabled_capability("resources")
(
user_api_key_auth,
mcp_auth_header,
@@ -3236,6 +3247,13 @@ def _reject_mcp_proxy_operation() -> NoReturn:
raise MCPError(code=METHOD_NOT_FOUND, message="Operation unavailable on /mcp/proxy")
+def _reject_disabled_capability(capability: str) -> NoReturn:
+ from mcp.shared.exceptions import MCPError
+ from mcp.types import METHOD_NOT_FOUND
+
+ raise MCPError(code=METHOD_NOT_FOUND, message=f"MCP {capability} are disabled on this gateway")
+
+
def prepare_context(
user_api_key_auth: UserAPIKeyAuth | None = None,
mcp_auth_header: str | None = None,
@@ -3375,10 +3393,14 @@ class GatewayOperations:
async def _execute(self, operation: GatewayOperation, context: OperationContext) -> GatewayResult:
match operation:
case DiscoverRequest():
+ disabled: Final = disabled_capabilities()
listings: Final = (
()
if context.mcp_proxy_mode
- else (ListPromptsRequest(), ListResourcesRequest(), ListResourceTemplatesRequest())
+ else (
+ *(() if "prompts" in disabled else (ListPromptsRequest(),)),
+ *(() if "resources" in disabled else (ListResourcesRequest(), ListResourceTemplatesRequest())),
+ )
)
memo_token: Final = _mcp_server_admission_memo.set({})
tasks: Final = (
@@ -3405,7 +3427,7 @@ class GatewayOperations:
configured=configured_versions(),
revision=context.protocol_version or "2025-11-25",
transport=MCPTransport.http,
- authorized_operations=GATEWAY_OPERATIONS,
+ authorized_operations=enabled_gateway_operations(disabled),
upstream_versions=frozenset(MCP_LEGACY_VERSIONS),
capabilities=ServerCapabilities(
tools=ToolsCapability()
diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py
index e5f9a211945..76156e9878e 100644
--- a/litellm/proxy/_experimental/mcp_server/server.py
+++ b/litellm/proxy/_experimental/mcp_server/server.py
@@ -13,7 +13,7 @@ import os
import time
import types
from collections import Counter
-from collections.abc import AsyncGenerator, AsyncIterator, Callable, Iterable, Mapping, Sequence
+from collections.abc import AsyncGenerator, AsyncIterator, Callable, Iterable, Mapping, Sequence, Set
from typing import TYPE_CHECKING, Final, NoReturn, Protocol
import httpx
@@ -569,7 +569,12 @@ if MCP_AVAILABLE:
from litellm.proxy._experimental.mcp_server.auth.litellm_auth_handler import (
MCPAuthenticatedUser,
)
- from litellm.proxy._experimental.mcp_server.capabilities import GatewayVersionPolicy, configured_versions
+ from litellm.proxy._experimental.mcp_server.capabilities import (
+ GatewayVersionPolicy,
+ configured_versions,
+ disabled_capabilities,
+ disabled_operations,
+ )
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
MCPServerManager,
global_mcp_server_manager,
@@ -1393,6 +1398,12 @@ if MCP_AVAILABLE:
except (json.JSONDecodeError, UnicodeDecodeError, TypeError):
return False
+ def _jsonrpc_request_method(body: bytes) -> str | None:
+ try:
+ return JSONRPCRequest.model_validate_json(body).method
+ except ValidationError:
+ return None
+
def _extract_initialize_client_info(body: bytes) -> Implementation | None:
try:
return InitializeRequest.model_validate_json(body, by_name=False).params.client_info
@@ -2285,6 +2296,87 @@ if MCP_AVAILABLE:
)
return None
+ async def _preflight_streamable_http_upstream_auth(
+ defer_upstream_probes: bool,
+ targets_disabled_capability: bool,
+ scope: Scope,
+ mcp_servers: Sequence[str] | None,
+ oauth2_headers: Mapping[str, str] | None,
+ mcp_server_auth_headers: Mapping[str, Mapping[str, str]] | None,
+ user_api_key_auth: UserAPIKeyAuth | None,
+ client_ip: str | None,
+ allowed_server_ids: Set[str] | None,
+ raw_headers: Mapping[str, str] | None,
+ ) -> None:
+ if not defer_upstream_probes and not targets_disabled_capability:
+ await _raise_preemptive_401_for_unauthenticated_servers(
+ scope=scope,
+ mcp_servers=list(mcp_servers) if mcp_servers is not None else None,
+ oauth2_headers=dict(oauth2_headers) if oauth2_headers is not None else None,
+ mcp_server_auth_headers=(
+ {name: dict(headers) for name, headers in mcp_server_auth_headers.items()}
+ if mcp_server_auth_headers is not None
+ else None
+ ),
+ user_api_key_auth=user_api_key_auth,
+ client_ip=client_ip,
+ allowed_server_ids=set(allowed_server_ids) if allowed_server_ids is not None else None,
+ raw_headers=raw_headers,
+ )
+ # Pre-flight auth check for pass-through servers. Must run after
+ # toolset scoping so the probe list is derived from the fully-authorized
+ # server set, not the raw user-supplied names.
+ await _check_passthrough_upstream_auth(
+ scope,
+ user_api_key_auth,
+ list(mcp_servers) if mcp_servers is not None else None,
+ client_ip,
+ oauth2_headers=oauth2_headers,
+ mcp_server_auth_headers=(
+ {name: dict(headers) for name, headers in mcp_server_auth_headers.items()}
+ if mcp_server_auth_headers is not None
+ else None
+ ),
+ raw_headers=raw_headers,
+ )
+
+ async def _targets_disabled_capability(peek: _LazyPeekedBody | None) -> bool:
+ if peek is None:
+ return False
+ disabled_ops: Final = disabled_operations(disabled_capabilities())
+ if not disabled_ops:
+ return False
+ return _jsonrpc_request_method(await peek.full_body()) in disabled_ops
+
+ async def _preflight_streamable_http_modern_interaction(
+ peek: _LazyPeekedBody | None,
+ defer_upstream_probes: bool,
+ targets_disabled_capability: bool,
+ scope: Scope,
+ user_api_key_auth: UserAPIKeyAuth | None,
+ mcp_auth_header: str | None,
+ mcp_servers: Sequence[str] | None,
+ mcp_server_auth_headers: Mapping[str, Mapping[str, str]] | None,
+ oauth2_headers: Mapping[str, str] | None,
+ raw_headers: Mapping[str, str] | None,
+ client_ip: str | None,
+ ) -> JSONResponse | None:
+ if peek is None or not defer_upstream_probes or targets_disabled_capability:
+ return None
+ return await _preflight_modern_interaction(
+ scope,
+ await peek.full_body(),
+ OperationContext(
+ _caller=user_api_key_auth,
+ mcp_auth_header=mcp_auth_header,
+ mcp_servers=tuple(mcp_servers) if mcp_servers is not None else None,
+ mcp_server_auth_headers=mcp_server_auth_headers,
+ oauth2_headers=oauth2_headers,
+ raw_headers=raw_headers,
+ client_ip=client_ip,
+ ),
+ )
+
async def handle_streamable_http_mcp(scope: Scope, receive: Receive, send: Send) -> None:
"""Handle MCP requests through StreamableHTTP."""
try:
@@ -2329,6 +2421,7 @@ if MCP_AVAILABLE:
defer_upstream_probes: Final = request_headers.get(
"mcp-protocol-version"
) == "2026-07-28" and request_headers.get("mcp-method") in {"tools/call", "prompts/get", "resources/read"}
+ targets_disabled_capability: Final = await _targets_disabled_capability(peek)
# Extract client IP for MCP access control
_client_ip: Final = IPAddressUtils.get_mcp_client_ip(StarletteRequest(scope))
@@ -2357,30 +2450,18 @@ if MCP_AVAILABLE:
# from the fully-authorized server set: a passthrough server that
# the active toolset excludes should not trigger an OAuth flow
# for a server the caller will be 403'd on after authentication.
- if not defer_upstream_probes:
- await _raise_preemptive_401_for_unauthenticated_servers(
- scope=scope,
- mcp_servers=mcp_servers,
- oauth2_headers=oauth2_headers,
- mcp_server_auth_headers=mcp_server_auth_headers,
- user_api_key_auth=user_api_key_auth,
- client_ip=_client_ip,
- allowed_server_ids=toolset_allowed_server_ids,
- raw_headers=raw_headers,
- )
-
- # Pre-flight auth check for pass-through servers. Must run after
- # toolset scoping so the probe list is derived from the fully-authorized
- # server set, not the raw user-supplied names.
- await _check_passthrough_upstream_auth(
- scope,
- user_api_key_auth,
- mcp_servers,
- _client_ip,
- oauth2_headers=oauth2_headers,
- mcp_server_auth_headers=mcp_server_auth_headers,
- raw_headers=raw_headers,
- )
+ await _preflight_streamable_http_upstream_auth(
+ defer_upstream_probes=defer_upstream_probes,
+ targets_disabled_capability=targets_disabled_capability,
+ scope=scope,
+ mcp_servers=mcp_servers,
+ oauth2_headers=oauth2_headers,
+ mcp_server_auth_headers=mcp_server_auth_headers,
+ user_api_key_auth=user_api_key_auth,
+ client_ip=_client_ip,
+ allowed_server_ids=toolset_allowed_server_ids,
+ raw_headers=raw_headers,
+ )
# Inject masked debug headers when client sends x-litellm-mcp-debug: true
_debug_headers: Final = MCPDebug.maybe_build_debug_headers(
@@ -2444,23 +2525,22 @@ if MCP_AVAILABLE:
return
session_id = _get_session_id_from_scope(scope)
- if peek is not None and defer_upstream_probes:
- rejection: Final = await _preflight_modern_interaction(
- scope,
- await peek.full_body(),
- OperationContext(
- _caller=user_api_key_auth,
- mcp_auth_header=mcp_auth_header,
- mcp_servers=tuple(mcp_servers) if mcp_servers is not None else None,
- mcp_server_auth_headers=mcp_server_auth_headers,
- oauth2_headers=oauth2_headers,
- raw_headers=raw_headers,
- client_ip=_client_ip,
- ),
- )
- if rejection is not None:
- await rejection(scope, receive, send)
- return
+ rejection: Final = await _preflight_streamable_http_modern_interaction(
+ peek=peek,
+ defer_upstream_probes=defer_upstream_probes,
+ targets_disabled_capability=targets_disabled_capability,
+ scope=scope,
+ user_api_key_auth=user_api_key_auth,
+ mcp_auth_header=mcp_auth_header,
+ mcp_servers=mcp_servers,
+ mcp_server_auth_headers=mcp_server_auth_headers,
+ oauth2_headers=oauth2_headers,
+ raw_headers=raw_headers,
+ client_ip=_client_ip,
+ )
+ if rejection is not None:
+ await rejection(scope, receive, send)
+ return
scope_response: Final = (
OAuthScopeResponse(
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index 6608e152466..682a5a56ee9 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -62,6 +62,7 @@ from litellm.types.mcp import (
MCPAuth,
MCPAuthType,
MCPCredentials,
+ MCPDisabledCapabilities,
MCPTransport,
MCPTransportType,
MCPUpstreamProtocol,
@@ -2885,6 +2886,13 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
description="MCP revisions enabled by the gateway. Defaults to all completed legacy revisions. "
"Modern protocol serving requires explicit opt-in. Apps/Tasks remain disabled.",
)
+ mcp_disabled_capabilities: MCPDisabledCapabilities | None = Field(
+ None,
+ description="MCP capabilities the gateway stops serving on every route, any of 'prompts' and 'resources'. "
+ "A disabled capability is left out of the initialize and server/discover responses, its list operations "
+ "return an empty list without contacting any upstream MCP server, and its get/read operations answer "
+ "METHOD_NOT_FOUND. Unset means prompts and resources stay advertised.",
+ )
mcp_allowed_clients: list[MCPAllowedClient] | None = Field(
None,
description="MCP client applications admitted by the gateway, each an {alias, value} pair where alias is the name shown in the dashboard and logs and value is the identity that must match exactly. When set, every MCP request must carry a client identity equal to one of the values: a JWT caller is identified by the claim named in litellm_jwtauth.mcp_client_id_jwt_field, any other caller by the header named in mcp_client_id_header. A request with no resolvable identity, or an unlisted one, is rejected with 403. Unset means every client is admitted.",
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index 4459172cf58..e5bceb25ef3 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -6927,6 +6927,12 @@ class ProxyConfig:
TypeAdapter(MCPAdvertisedVersions).validate_python(general_settings["mcp_advertised_versions"])
+ disabled_mcp_capabilities: Final = typed_general_settings.get("mcp_disabled_capabilities")
+ if disabled_mcp_capabilities is not None:
+ from litellm.types.mcp import MCPDisabledCapabilities
+
+ TypeAdapter(MCPDisabledCapabilities).validate_python(disabled_mcp_capabilities)
+
if os.getenv("NUM_WORKERS", "1") != "1" and redis_usage_cache is None:
warn_login_counters_are_per_worker(os.getenv("NUM_WORKERS", "1"))
if declared_proxy_ranges(general_settings) is None:
@@ -18814,6 +18820,7 @@ _GENERAL_SETTINGS_CONFIG_LIST_FIELD_TYPES: Final[Mapping[str, str]] = MappingPro
"always_include_stream_usage": "Boolean",
"forward_client_headers_to_llm_api": "Boolean",
"mcp_required_fields": "List",
+ "mcp_disabled_capabilities": "List",
"cancel_on_disconnect": "Boolean",
"disable_auto_add_proxy_admin_to_teams": "Boolean",
"apply_user_budget_to_team_keys": "Boolean",
diff --git a/litellm/types/mcp.py b/litellm/types/mcp.py
index 5dcbf77993a..72e564b6919 100644
--- a/litellm/types/mcp.py
+++ b/litellm/types/mcp.py
@@ -73,6 +73,8 @@ def validate_mcp_protocol_transport(protocol_version: MCPUpstreamProtocol, trans
MCPAdvertisedVersion: TypeAlias = MCPLegacyVersion | Literal["2026-07-28"]
MCPAdvertisedVersions: TypeAlias = Annotated[tuple[MCPAdvertisedVersion, ...], Field(min_length=1)]
+MCPDisableableCapability: TypeAlias = Literal["prompts", "resources"]
+MCPDisabledCapabilities: TypeAlias = tuple[MCPDisableableCapability, ...]
MCPSpecVersionType = Literal[
MCPSpecVersion.nov_2024,
MCPSpecVersion.mar_2025,
diff --git a/tests/unit/proxy/_experimental/mcp_server/test_capabilities.py b/tests/unit/proxy/_experimental/mcp_server/test_capabilities.py
index f8e3655b2f2..39a6b1fcf15 100644
--- a/tests/unit/proxy/_experimental/mcp_server/test_capabilities.py
+++ b/tests/unit/proxy/_experimental/mcp_server/test_capabilities.py
@@ -4,7 +4,15 @@ import pytest
from mcp import Client
from mcp.server import Server
from mcp.shared.exceptions import MCPError
-from mcp.types import PromptsCapability, ResourcesCapability, ServerCapabilities, ToolsCapability
+from mcp.types import (
+ ListPromptsResult,
+ ListResourcesResult,
+ PaginatedRequestParams,
+ PromptsCapability,
+ ResourcesCapability,
+ ServerCapabilities,
+ ToolsCapability,
+)
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS
from litellm.proxy._experimental.mcp_server.capabilities import (
@@ -13,6 +21,8 @@ from litellm.proxy._experimental.mcp_server.capabilities import (
TRANSLATION_PAIRS,
GatewayVersionPolicy,
build_discovery,
+ disabled_capabilities,
+ enabled_gateway_operations,
)
from litellm.types.mcp import MCPTransport
@@ -27,7 +37,9 @@ def test_discovery_only_exposes_authorized_completed_support(revision, transport
authorized_operations=frozenset({"tools/list", "tools/call"}),
upstream_versions=frozenset(HANDSHAKE_PROTOCOL_VERSIONS),
capabilities=ServerCapabilities(
- tools=ToolsCapability(), prompts=PromptsCapability(), resources=ResourcesCapability(),
+ tools=ToolsCapability(),
+ prompts=PromptsCapability(),
+ resources=ResourcesCapability(),
extensions={"io.modelcontextprotocol/ui": {}},
),
client_extensions=frozenset({"io.modelcontextprotocol/ui"}),
@@ -59,18 +71,26 @@ def test_unproven_translation_never_advertises_operations(upstream):
@pytest.mark.parametrize("revision", ["2026-07-28", "unknown", "2024-11-05"])
def test_unadvertised_revision_never_gains_capabilities(revision):
result = build_discovery(
- configured=("2025-11-25",), revision=revision, transport=MCPTransport.http,
- authorized_operations=GATEWAY_OPERATIONS, upstream_versions=frozenset(HANDSHAKE_PROTOCOL_VERSIONS),
+ configured=("2025-11-25",),
+ revision=revision,
+ transport=MCPTransport.http,
+ authorized_operations=GATEWAY_OPERATIONS,
+ upstream_versions=frozenset(HANDSHAKE_PROTOCOL_VERSIONS),
capabilities=ServerCapabilities(tools=ToolsCapability()),
)
assert result.capabilities.tools is None
def test_discovery_results_do_not_share_mutable_capabilities():
- capabilities = ServerCapabilities(tools=ToolsCapability(), prompts=PromptsCapability(), resources=ResourcesCapability())
+ capabilities = ServerCapabilities(
+ tools=ToolsCapability(), prompts=PromptsCapability(), resources=ResourcesCapability()
+ )
args = dict(
- configured=HANDSHAKE_PROTOCOL_VERSIONS, revision="2025-11-25", transport=MCPTransport.http,
- upstream_versions=frozenset(HANDSHAKE_PROTOCOL_VERSIONS), capabilities=capabilities,
+ configured=HANDSHAKE_PROTOCOL_VERSIONS,
+ revision="2025-11-25",
+ transport=MCPTransport.http,
+ upstream_versions=frozenset(HANDSHAKE_PROTOCOL_VERSIONS),
+ capabilities=capabilities,
)
allowed = build_discovery(**args, authorized_operations=GATEWAY_OPERATIONS)
denied = build_discovery(**args, authorized_operations=frozenset())
@@ -101,7 +121,9 @@ async def test_version_policy_gates_the_actual_sdk_handshake(versions, accepted)
result = await client.session.send_ping()
assert result is not None
else:
- with pytest.RaisesGroup(pytest.RaisesExc(MCPError, match="Unsupported MCP protocol version"), flatten_subgroups=True):
+ with pytest.RaisesGroup(
+ pytest.RaisesExc(MCPError, match="Unsupported MCP protocol version"), flatten_subgroups=True
+ ):
async with Client(server, mode="legacy"):
pytest.fail("The excluded revision must not initialize")
@@ -140,3 +162,54 @@ def test_modern_protocol_is_rejected_on_legacy_sse_paths(path: str, monkeypatch:
unsupported_protocol_version({"path": path, "headers": [(b"mcp-protocol-version", b"2026-07-28")]})
== "2026-07-28"
)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "disabled,prompts_advertised,resources_advertised",
+ [
+ (frozenset(), True, True),
+ (frozenset({"prompts"}), False, True),
+ (frozenset({"resources"}), True, False),
+ (frozenset({"prompts", "resources"}), False, False),
+ ],
+)
+async def test_version_policy_hides_disabled_capabilities_from_the_handshake(
+ disabled, prompts_advertised, resources_advertised
+) -> None:
+ server: Final = Server("test-gateway", version="1")
+
+ async def list_prompts(ctx: object, params: PaginatedRequestParams) -> ListPromptsResult:
+ return ListPromptsResult(prompts=[])
+
+ async def list_resources(ctx: object, params: PaginatedRequestParams) -> ListResourcesResult:
+ return ListResourcesResult(resources=[])
+
+ server.add_request_handler("prompts/list", PaginatedRequestParams, list_prompts)
+ server.add_request_handler("resources/list", PaginatedRequestParams, list_resources)
+ server.middleware.append(GatewayVersionPolicy(lambda: ("2025-11-25",), lambda: disabled))
+ async with Client(server, mode="legacy") as client:
+ assert (client.server_capabilities.prompts is not None) is prompts_advertised
+ assert (client.server_capabilities.resources is not None) is resources_advertised
+
+
+@pytest.mark.parametrize(
+ "configured,expected",
+ [
+ (None, frozenset()),
+ (["prompts"], frozenset({"prompts"})),
+ (["prompts", "resources"], frozenset({"prompts", "resources"})),
+ ],
+)
+def test_disabled_capabilities_follow_general_settings(configured, expected, monkeypatch: pytest.MonkeyPatch) -> None:
+ from litellm.proxy import proxy_server
+
+ monkeypatch.setitem(proxy_server.general_settings, "mcp_disabled_capabilities", configured)
+ assert disabled_capabilities() == expected
+
+
+def test_enabled_gateway_operations_only_drop_the_disabled_capability() -> None:
+ assert enabled_gateway_operations(frozenset()) == GATEWAY_OPERATIONS
+ remaining: Final = enabled_gateway_operations(frozenset({"resources"}))
+ assert {"tools/list", "tools/call", "prompts/list", "prompts/get"} <= remaining
+ assert not any(operation.startswith("resources/") for operation in remaining)
diff --git a/tests/unit/proxy/_experimental/mcp_server/test_mcp_stale_session.py b/tests/unit/proxy/_experimental/mcp_server/test_mcp_stale_session.py
index bf1f0f97a18..66d853bdd44 100644
--- a/tests/unit/proxy/_experimental/mcp_server/test_mcp_stale_session.py
+++ b/tests/unit/proxy/_experimental/mcp_server/test_mcp_stale_session.py
@@ -8,6 +8,8 @@ they may send a stale `mcp-session-id` header. This test verifies that:
"""
import asyncio
+import json
+from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -1737,3 +1739,150 @@ async def test_handle_streamable_http_mcp_true_passthrough_with_token_skips_prob
assert mock_handle_request.await_count == 1
probe_client.post.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "disabled,method,params,expected_preflight_calls,extra_request_headers,expected_modern_preflight_calls",
+ [
+ (("prompts", "resources"), "prompts/list", {}, 0, (), 0),
+ (("prompts", "resources"), "prompts/get", {"name": "x"}, 0, (), 0),
+ (("prompts", "resources"), "resources/list", {}, 0, (), 0),
+ (("prompts", "resources"), "resources/read", {"uri": "file:///x"}, 0, (), 0),
+ (("prompts", "resources"), "resources/templates/list", {}, 0, (), 0),
+ (("prompts", "resources"), "tools/list", {}, 1, (), 0),
+ (None, "prompts/list", {}, 1, (), 0),
+ (("prompts",), "resources/list", {}, 1, (), 0),
+ (
+ ("prompts", "resources"),
+ "prompts/get",
+ {"name": "x"},
+ 0,
+ ((b"mcp-protocol-version", b"2026-07-28"), (b"mcp-method", b"prompts/get")),
+ 0,
+ ),
+ (
+ ("prompts", "resources"),
+ "resources/read",
+ {"uri": "file:///x"},
+ 0,
+ ((b"mcp-protocol-version", b"2026-07-28"), (b"mcp-method", b"resources/read")),
+ 0,
+ ),
+ (
+ ("prompts", "resources"),
+ "tools/call",
+ {"name": "tp_server-add", "arguments": {}},
+ 0,
+ ((b"mcp-protocol-version", b"2026-07-28"), (b"mcp-method", b"tools/call")),
+ 1,
+ ),
+ (
+ None,
+ "prompts/get",
+ {"name": "x"},
+ 0,
+ ((b"mcp-protocol-version", b"2026-07-28"), (b"mcp-method", b"prompts/get")),
+ 1,
+ ),
+ (
+ ("prompts", "resources"),
+ "prompts/get",
+ {"name": "x", "arguments": {"pad": "a" * 5000}},
+ 0,
+ ((b"mcp-protocol-version", b"2026-07-28"), (b"mcp-method", b"prompts/get")),
+ 0,
+ ),
+ (None, "prompts/get", {"name": "x", "arguments": {"pad": "a" * 5000}}, 1, (), 0),
+ ],
+)
+async def test_streamable_http_skips_preflight_for_disabled_methods( # test-quality-ok: no injectable preflight seam
+ disabled: tuple[str, ...] | None,
+ method: str,
+ params: dict[str, object],
+ expected_preflight_calls: int,
+ extra_request_headers: tuple[tuple[bytes, bytes], ...],
+ expected_modern_preflight_calls: int,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ from litellm.proxy import proxy_server
+ from litellm.proxy._experimental.mcp_server.server import (
+ handle_streamable_http_mcp,
+ session_manager_stateless,
+ )
+
+ monkeypatch.setitem(proxy_server.general_settings, "mcp_advertised_versions", ["2026-07-28"])
+ if disabled is None:
+ monkeypatch.delitem(proxy_server.general_settings, "mcp_disabled_capabilities", raising=False)
+ else:
+ monkeypatch.setitem(proxy_server.general_settings, "mcp_disabled_capabilities", list(disabled))
+
+ probe_client: Final = MagicMock()
+ probe_client.post = AsyncMock()
+ request_headers: Final = [
+ (b"authorization", b"Bearer upstream-token"),
+ *extra_request_headers,
+ ]
+ scope: Final = _passthrough_mode_scope(
+ "tp_server",
+ extra_headers=request_headers,
+ )
+ body: Final[bytes] = json.dumps(
+ {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
+ ).encode()
+ receive: Final = AsyncMock(return_value={"type": "http.request", "body": body, "more_body": False})
+ send: Final = AsyncMock()
+ user_auth: Final = UserAPIKeyAuth()
+ user_auth.user_id = None
+ tp_server: Final = _build_passthrough_mode_server("tp_server", MCPAuth.true_passthrough)
+
+ with (
+ patch(
+ "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context",
+ new_callable=AsyncMock,
+ return_value=(user_auth, None, ["tp_server"], None, None, None),
+ ),
+ patch("litellm.proxy._experimental.mcp_server.server.set_auth_context"),
+ patch(
+ "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED",
+ True,
+ ),
+ patch(
+ "litellm.proxy._experimental.mcp_server.server._handle_stale_mcp_session",
+ new_callable=AsyncMock,
+ return_value=False,
+ ),
+ patch(
+ "litellm.proxy._experimental.mcp_server.server._raise_preemptive_401_for_unauthenticated_servers",
+ new_callable=AsyncMock,
+ ) as mock_preemptive_401,
+ patch(
+ "litellm.proxy._experimental.mcp_server.server._check_passthrough_upstream_auth",
+ new_callable=AsyncMock,
+ ) as mock_passthrough_auth,
+ patch(
+ "litellm.proxy._experimental.mcp_server.server._preflight_modern_interaction",
+ new_callable=AsyncMock,
+ return_value=None,
+ ) as mock_modern_preflight,
+ patch(
+ "litellm.proxy._experimental.mcp_server.server.get_async_httpx_client",
+ return_value=probe_client,
+ ),
+ patch(
+ "litellm.proxy._experimental.mcp_server.operations.global_mcp_server_manager.get_mcp_server_by_name",
+ return_value=tp_server,
+ ),
+ patch.object(
+ session_manager_stateless,
+ "handle_request",
+ new_callable=AsyncMock,
+ ) as mock_handle_request,
+ patch.object(session_manager_stateless, "_server_instances", {}),
+ ):
+ await handle_streamable_http_mcp(scope, receive, send)
+
+ assert mock_preemptive_401.await_count == expected_preflight_calls
+ assert mock_passthrough_auth.await_count == expected_preflight_calls
+ assert mock_modern_preflight.await_count == expected_modern_preflight_calls
+ assert mock_handle_request.await_count == 1
diff --git a/tests/unit/proxy/_experimental/mcp_server/test_operations.py b/tests/unit/proxy/_experimental/mcp_server/test_operations.py
index e96664aae84..5d8db1e6e2b 100644
--- a/tests/unit/proxy/_experimental/mcp_server/test_operations.py
+++ b/tests/unit/proxy/_experimental/mcp_server/test_operations.py
@@ -48,8 +48,11 @@ async def _served_catalog_tool() -> str:
@pytest.mark.parametrize("mode", ["direct", "virtual", "proxy"])
@pytest.mark.parametrize("verdict", ["allow", "block", "obo_error", "evaluate_error", "missing"])
async def test_mapped_caller_bearer_reaches_agent_evaluation(
- monkeypatch: pytest.MonkeyPatch, _mcp_request_ctx: Callable[..., ServerRequestContext],
- mode: str, verdict: str, caplog: pytest.LogCaptureFixture,
+ monkeypatch: pytest.MonkeyPatch,
+ _mcp_request_ctx: Callable[..., ServerRequestContext],
+ mode: str,
+ verdict: str,
+ caplog: pytest.LogCaptureFixture,
) -> None:
from urllib.parse import parse_qs
@@ -66,18 +69,31 @@ async def test_mapped_caller_bearer_reaches_agent_evaluation(
from litellm.proxy.guardrails.guardrail_hooks.agent_365.agent_365 import Agent365Guardrail
bearer: Final = "signed.caller.assertion"
- request: Final = Request({
- "type": "http", "method": "POST", "path": "/mcp",
- "headers": [] if verdict == "missing" else [(b"authorization", f"Bearer {bearer}".encode())],
- })
+ request: Final = Request(
+ {
+ "type": "http",
+ "method": "POST",
+ "path": "/mcp",
+ "headers": [] if verdict == "missing" else [(b"authorization", f"Bearer {bearer}".encode())],
+ }
+ )
auth: Final = UserAPIKeyAuth(
- api_key="stored-key-hash", user_id="mapped-caller", jwt_claims={"sub": "mapped-caller"},
- object_permission={"object_permission_id": "mapped", "mcp_servers": ["guarded"], "mcp_tool_search_enabled": mode == "virtual"},
+ api_key="stored-key-hash",
+ user_id="mapped-caller",
+ jwt_claims={"sub": "mapped-caller"},
+ object_permission={
+ "object_permission_id": "mapped",
+ "mcp_servers": ["guarded"],
+ "mcp_tool_search_enabled": mode == "virtual",
+ },
)
oauth, raw, _, _ = MCPRequestHandler.scrub_gateway_admission_credentials(
- admitted=False, admitted_credential=bearer,
+ admitted=False,
+ admitted_credential=bearer,
oauth2_headers={"Authorization": f"Bearer {bearer}"},
- raw_headers=dict(request.headers), mcp_auth_header=None, mcp_server_auth_headers=None,
+ raw_headers=dict(request.headers),
+ mcp_auth_header=None,
+ mcp_server_auth_headers=None,
)
assert "authorization" not in raw
provider_requests: Final[asyncio.Queue[str]] = asyncio.Queue()
@@ -92,9 +108,13 @@ async def test_mapped_caller_bearer_reaches_agent_evaluation(
assert exchange.headers["authorization"] == "Bearer delegated-token"
if verdict == "evaluate_error":
return httpx.Response(503, json={"error": "unavailable"})
- return httpx.Response(200, json={
- "allowed": verdict == "allow", "defender": {"status": "Evaluated", "verdict": "Allow" if verdict == "allow" else "Block"},
- })
+ return httpx.Response(
+ 200,
+ json={
+ "allowed": verdict == "allow",
+ "defender": {"status": "Evaluated", "verdict": "Allow" if verdict == "allow" else "Block"},
+ },
+ )
executions: Final[asyncio.Queue[str]] = asyncio.Queue()
@@ -107,36 +127,55 @@ async def test_mapped_caller_bearer_reaches_agent_evaluation(
handler: Final = AsyncHTTPHandler(transport=httpx.MockTransport(provider))
guardrail: Final = Agent365Guardrail(
- guardrail_name="agent-evaluation", tenant_id="tenant", client_id="gateway", client_secret="secret",
- async_handler=handler, event_hook="pre_mcp_call", default_on=True,
+ guardrail_name="agent-evaluation",
+ tenant_id="tenant",
+ client_id="gateway",
+ client_secret="secret",
+ async_handler=handler,
+ event_hook="pre_mcp_call",
+ default_on=True,
)
monkeypatch.setattr(litellm, "callbacks", [guardrail])
managed: Final = MCPServer(
- server_id="guarded", name="guarded", transport=MCPTransport.http,
- spec_path="/catalog.yaml", allow_all_keys=True,
+ server_id="guarded",
+ name="guarded",
+ transport=MCPTransport.http,
+ spec_path="/catalog.yaml",
+ allow_all_keys=True,
)
monkeypatch.setitem(operations.global_mcp_server_manager.registry, managed.server_id, managed)
global_mcp_tool_registry.register_tool(
- name="guarded-echo", description="Echo", input_schema={"type": "object"}, handler=tool,
+ name="guarded-echo",
+ description="Echo",
+ input_schema={"type": "object"},
+ handler=tool,
)
previous: Final = auth_context_var.set(None)
proxy_mode: Final = _mcp_proxy_mode.set(mode == "proxy")
- tool_id: Final = mcp_proxy_tool_id(with_mcp_proxy_identity(
- MCPTool(name="guarded-echo", input_schema={"type": "object"}), managed.server_id,
- ))
+ tool_id: Final = mcp_proxy_tool_id(
+ with_mcp_proxy_identity(
+ MCPTool(name="guarded-echo", input_schema={"type": "object"}),
+ managed.server_id,
+ )
+ )
params: Final = {
"direct": CallToolRequestParams(name="guarded-echo", arguments={}),
- "virtual": CallToolRequestParams(name="mcp_tool_call", arguments={"tool_name": "guarded-echo", "arguments": {}}),
+ "virtual": CallToolRequestParams(
+ name="mcp_tool_call", arguments={"tool_name": "guarded-echo", "arguments": {}}
+ ),
"proxy": CallToolRequestParams(name="call_tool", arguments={"tool_id": tool_id, "arguments": {}}),
}[mode]
try:
gateway.set_auth_context(
- auth, mcp_servers=[managed.server_id], oauth2_headers=oauth,
+ auth,
+ mcp_servers=[managed.server_id],
+ oauth2_headers=oauth,
raw_headers={"authorization": "Bearer previous.caller.assertion"} if verdict == "missing" else raw,
)
async with gateway._legacy_operation_context(_mcp_request_ctx(request=request), trace=False) as context:
result: Final = await GatewayOperations().execute(
- CallToolRequest(params=params), context,
+ CallToolRequest(params=params),
+ context,
)
assert isinstance(result, CallToolResult)
assert result.is_error is (verdict != "allow")
@@ -162,12 +201,16 @@ async def test_listing_records_only_tools_the_caller_received(
) -> None:
manager: Final = operations.global_mcp_server_manager
server: Final = MCPServer(
- server_id="served-catalog", name="served-catalog", transport=MCPTransport.http,
- spec_path="/catalog.yaml", allow_all_keys=True,
+ server_id="served-catalog",
+ name="served-catalog",
+ transport=MCPTransport.http,
+ spec_path="/catalog.yaml",
+ allow_all_keys=True,
allowed_tools=["echo"] if restriction == "server" else None,
)
auth: Final = UserAPIKeyAuth(
- api_key="sk-served-catalog", user_id="lister",
+ api_key="sk-served-catalog",
+ user_id="lister",
object_permission={
"object_permission_id": "served-permission",
"mcp_servers": [server.server_id],
@@ -181,18 +224,24 @@ async def test_listing_records_only_tools_the_caller_received(
monkeypatch.setattr(litellm, "callbacks", [capture])
for name in ("echo", "status"):
global_mcp_tool_registry.register_tool(
- name=f"served-catalog-{name}", description=f"{name} description",
- input_schema={"type": "object"}, handler=_served_catalog_tool,
+ name=f"served-catalog-{name}",
+ description=f"{name} description",
+ input_schema={"type": "object"},
+ handler=_served_catalog_tool,
)
try:
if surface == "mcp":
listing: Final = await operations._list_mcp_tools(
- user_api_key_auth=auth, mcp_servers=[server.server_id], record_listing=True,
+ user_api_key_auth=auth,
+ mcp_servers=[server.server_id],
+ record_listing=True,
)
assert [tool.name for tool in listing.tools] == ["served-catalog-echo"]
else:
rest_listing: Final = await rest_endpoints._get_tools_for_single_server(
- server, None, user_api_key_auth=auth,
+ server,
+ None,
+ user_api_key_auth=auth,
)
assert [tool.name for tool in rest_listing] == ["echo"]
granted: Final = auth.model_copy(update={"object_permission": None})
@@ -203,7 +252,10 @@ async def test_listing_records_only_tools_the_caller_received(
assert (served.description, served.input_schema) == ("echo description", {"type": "object"})
server.allowed_tools = None
result: Final = await manager.call_tool(
- server_name=server.server_id, name="status", arguments={}, user_api_key_auth=granted,
+ server_name=server.server_id,
+ name="status",
+ arguments={},
+ user_api_key_auth=granted,
proxy_logging_obj=ProxyLogging(user_api_key_cache=UserApiKeyCache()),
)
assert result.is_error is False
@@ -403,6 +455,54 @@ def _catalog_case(method):
return cases[method]
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "disabled,operation",
+ [
+ ("prompts", "prompts/list"),
+ ("prompts", "prompts/get"),
+ ("resources", "resources/list"),
+ ("resources", "resources/templates/list"),
+ ("resources", "resources/read"),
+ ],
+)
+async def test_disabled_capability_never_reaches_upstream(
+ disabled: str, operation: str, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ from unittest.mock import MagicMock
+
+ from mcp.shared.exceptions import MCPError
+ from mcp.types import METHOD_NOT_FOUND
+
+ from litellm.proxy import proxy_server
+ from litellm.proxy._experimental.mcp_server import catalog
+
+ monkeypatch.setitem(proxy_server.general_settings, "mcp_disabled_capabilities", [disabled])
+ request, _client_method, manager_method, upstream_value, result_field = _catalog_case(operation)
+ server: Final = MCPServer(
+ server_id="catalog-disabled", name="catalog", server_name="catalog", transport=MCPTransport.http
+ )
+ upstream: Final = AsyncMock(return_value=upstream_value)
+ manager: Final = operations.global_mcp_server_manager
+ context: Final = prepare_context(UserAPIKeyAuth(api_key=hash_token("sk-disabled")), mcp_servers=[server.server_id])
+ with (
+ patch.object(operations, "_get_allowed_mcp_servers", AsyncMock(return_value=[server])),
+ patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock(enforce_mcp_server_rate_limits=AsyncMock())),
+ patch("litellm.proxy.proxy_server.prisma_client", None),
+ patch.dict(manager.registry, {server.server_id: server}),
+ patch.object(manager, manager_method, upstream),
+ patch.object(catalog, "fetch_optional_catalog_page", upstream),
+ ):
+ if result_field is None:
+ with pytest.raises(MCPError) as rejected:
+ await GatewayOperations().execute(request, context)
+ assert rejected.value.error.code == METHOD_NOT_FOUND
+ else:
+ result = await GatewayOperations().execute(request, context)
+ assert getattr(result, result_field) == []
+ upstream.assert_not_awaited()
+
+
def _mcp_rate_limited_proxy_logging() -> ProxyLogging:
proxy_logging: Final = ProxyLogging(user_api_key_cache=UserApiKeyCache())
proxy_logging.proxy_hook_mapping["parallel_request_limiter"] = PROXY_MaxParallelRequestsHandler_v3(
@@ -452,9 +552,7 @@ async def test_mcp_server_rpm_limits_every_catalog_operation(operation: str) ->
resources=[types.Resource(name="document", uri="https://example.com/document")]
),
"resources/templates/list": types.ListResourceTemplatesResult(
- resource_templates=[
- types.ResourceTemplate(name="document", uri_template="https://example.com/{name}")
- ]
+ resource_templates=[types.ResourceTemplate(name="document", uri_template="https://example.com/{name}")]
),
"prompts/get": GetPromptResult(messages=[]),
"resources/read": types.ReadResourceResult(contents=[]),
@@ -537,9 +635,7 @@ async def test_tools_call_warmup_does_not_consume_mcp_server_rpm() -> None:
)
caller: Final = UserAPIKeyAuth(api_key=hash_token("sk-catalog-warmup"))
proxy_logging: Final = _mcp_rate_limited_proxy_logging()
- upstream: Final = AsyncMock(
- return_value=[types.Tool(name="echo", inputSchema={"type": "object"})]
- )
+ upstream: Final = AsyncMock(return_value=[types.Tool(name="echo", inputSchema={"type": "object"})])
with (
patch.object(operations, "_get_allowed_mcp_servers", AsyncMock(return_value=[server])),
patch.object(operations.global_mcp_server_manager, "server_exposes_tool", return_value=False),
@@ -565,7 +661,6 @@ async def test_tools_call_warmup_does_not_consume_mcp_server_rpm() -> None:
assert [tool.name for tool in listing.tools] == ["echo"]
-
def _scoped_rig(
monkeypatch: pytest.MonkeyPatch,
) -> tuple[MCPServer, MCPServer, AsyncMock, AsyncMock]:
@@ -585,9 +680,7 @@ def _scoped_rig(
manager.config_mcp_servers = {server.server_id: server for server in (memory, other)}
monkeypatch.setattr(proxy_server, "prisma_client", None)
monkeypatch.setattr(operations, "global_mcp_server_manager", manager)
- monkeypatch.setattr(
- manager, "get_allowed_mcp_servers", AsyncMock(return_value=[memory.server_id, other.server_id])
- )
+ monkeypatch.setattr(manager, "get_allowed_mcp_servers", AsyncMock(return_value=[memory.server_id, other.server_id]))
monkeypatch.setattr(global_mcp_tool_registry, "published_tools", {})
listing: Final = AsyncMock()
monkeypatch.setattr(operations, "_get_tools_from_mcp_servers", listing)
@@ -647,6 +740,7 @@ async def test_bare_tool_name_without_a_single_server_scope_is_still_unresolved_
listing.assert_not_awaited()
handled.assert_not_awaited()
+
@pytest.mark.asyncio
async def test_tools_call_pre_call_check_enforces_mcp_server_rpm() -> None:
from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager
@@ -794,8 +888,13 @@ async def test_catalog_helpers_preserve_context_results_and_failure_policy(metho
if collection:
helper = getattr(operations, "_list_mcp_" + collection)
result = await helper(
- user_api_key_auth=caller, mcp_auth_header=None, mcp_servers=["catalog"],
- mcp_server_auth_headers=None, oauth2_headers=None, raw_headers=headers, client_ip="192.0.2.41",
+ user_api_key_auth=caller,
+ mcp_auth_header=None,
+ mcp_servers=["catalog"],
+ mcp_server_auth_headers=None,
+ oauth2_headers=None,
+ raw_headers=headers,
+ client_ip="192.0.2.41",
)
assert result == (payload if state == "success" else [])
else:
@@ -1107,7 +1206,9 @@ async def test_discovery_preserves_caller_scope_and_proxy_restrictions():
from mcp.types import DiscoverRequest, ListToolsResult, Tool
listed = AsyncMock(return_value=ListToolsResult(tools=[Tool(name="allowed", input_schema={"type": "object"})]))
- context = prepare_context(UserAPIKeyAuth(user_id="scoped"), mcp_servers=["only-this"], mcp_proxy_mode=True, protocol_version="2025-06-18")
+ context = prepare_context(
+ UserAPIKeyAuth(user_id="scoped"), mcp_servers=["only-this"], mcp_proxy_mode=True, protocol_version="2025-06-18"
+ )
with patch("litellm.proxy._experimental.mcp_server.operations._execute_handle_list_tools", listed):
result = await GatewayOperations().execute(DiscoverRequest(), context)
assert result.capabilities.tools is not None
@@ -1134,16 +1235,34 @@ async def test_discovery_denial_cannot_advertise_tools():
@pytest.mark.parametrize("available", ["none", "resources", "templates", "prompts"])
async def test_discovery_lists_each_capability_with_the_same_caller(available):
from mcp.types import (
- DiscoverRequest, ListToolsResult, ListPromptsResult, ListResourcesResult,
- ListResourceTemplatesResult, Prompt, Resource, ResourceTemplate,
+ DiscoverRequest,
+ ListToolsResult,
+ ListPromptsResult,
+ ListResourcesResult,
+ ListResourceTemplatesResult,
+ Prompt,
+ Resource,
+ ResourceTemplate,
)
from litellm.proxy._experimental.mcp_server import operations
context = prepare_context(UserAPIKeyAuth(user_id="scoped"), mcp_servers=["authorized"])
tools = AsyncMock(return_value=ListToolsResult(tools=[]))
- prompts = AsyncMock(return_value=ListPromptsResult(prompts=[Prompt(name="allowed")] if available == "prompts" else []))
- resources = AsyncMock(return_value=ListResourcesResult(resources=[Resource(name="allowed", uri="test://allowed")] if available == "resources" else []))
- templates = AsyncMock(return_value=ListResourceTemplatesResult(resource_templates=[ResourceTemplate(name="allowed", uri_template="test://{id}")] if available == "templates" else []))
+ prompts = AsyncMock(
+ return_value=ListPromptsResult(prompts=[Prompt(name="allowed")] if available == "prompts" else [])
+ )
+ resources = AsyncMock(
+ return_value=ListResourcesResult(
+ resources=[Resource(name="allowed", uri="test://allowed")] if available == "resources" else []
+ )
+ )
+ templates = AsyncMock(
+ return_value=ListResourceTemplatesResult(
+ resource_templates=[ResourceTemplate(name="allowed", uri_template="test://{id}")]
+ if available == "templates"
+ else []
+ )
+ )
with (
patch.object(operations, "_execute_handle_list_tools", tools),
patch.object(operations, "_execute_list_prompts", prompts),
@@ -1158,6 +1277,50 @@ async def test_discovery_lists_each_capability_with_the_same_caller(available):
assert listing.await_args.args[0] is context
+@pytest.mark.asyncio
+@pytest.mark.parametrize("disabled", ["prompts", "resources"])
+async def test_discovery_skips_disabled_capability_listings(disabled: str, monkeypatch: pytest.MonkeyPatch) -> None:
+ from mcp.types import (
+ DiscoverRequest,
+ ListPromptsResult,
+ ListResourcesResult,
+ ListResourceTemplatesResult,
+ ListToolsResult,
+ Prompt,
+ Resource,
+ ResourceTemplate,
+ )
+
+ from litellm.proxy import proxy_server
+
+ monkeypatch.setitem(proxy_server.general_settings, "mcp_disabled_capabilities", [disabled])
+ context: Final = prepare_context(UserAPIKeyAuth(user_id="scoped"), mcp_servers=["authorized"])
+ tools: Final = AsyncMock(return_value=ListToolsResult(tools=[]))
+ prompts: Final = AsyncMock(return_value=ListPromptsResult(prompts=[Prompt(name="allowed")]))
+ resources: Final = AsyncMock(
+ return_value=ListResourcesResult(resources=[Resource(name="allowed", uri="test://allowed")])
+ )
+ templates: Final = AsyncMock(
+ return_value=ListResourceTemplatesResult(
+ resource_templates=[ResourceTemplate(name="allowed", uri_template="test://{id}")]
+ )
+ )
+ with (
+ patch.object(operations, "_execute_handle_list_tools", tools),
+ patch.object(operations, "_execute_list_prompts", prompts),
+ patch.object(operations, "_execute_list_resources", resources),
+ patch.object(operations, "_execute_list_resource_templates", templates),
+ ):
+ result = await GatewayOperations().execute(DiscoverRequest(), context)
+ assert (result.capabilities.prompts is None) == (disabled == "prompts")
+ assert (result.capabilities.resources is None) == (disabled == "resources")
+ if disabled == "prompts":
+ prompts.assert_not_awaited()
+ else:
+ resources.assert_not_awaited()
+ templates.assert_not_awaited()
+
+
@pytest.mark.asyncio
async def test_discovery_shares_one_server_admission_across_catalog_listings() -> None:
from mcp import types
@@ -1214,6 +1377,7 @@ async def test_discovery_shares_one_server_admission_across_catalog_listings() -
AsyncMock(side_effect=fetch_resources),
AsyncMock(side_effect=fetch_resource_templates),
)
+
async def fetch_optional_page(
_context: OperationContext,
request: types.ListPromptsRequest | types.ListResourcesRequest | types.ListResourceTemplatesRequest,
@@ -1257,13 +1421,24 @@ async def test_discovery_shares_one_server_admission_across_catalog_listings() -
@pytest.mark.asyncio
@pytest.mark.parametrize("outcome", ["success", "failure", "cancel"])
async def test_discovery_concurrent_listings_drain_on_failure_and_cancellation(outcome):
- from mcp.types import DiscoverRequest, ListToolsResult, ListPromptsResult, ListResourcesResult, ListResourceTemplatesResult
+ from mcp.types import (
+ DiscoverRequest,
+ ListToolsResult,
+ ListPromptsResult,
+ ListResourcesResult,
+ ListResourceTemplatesResult,
+ )
from litellm.proxy._experimental.mcp_server import operations
ready = [asyncio.Event() for _ in range(4)]
closed = [asyncio.Event() for _ in range(4)]
release = asyncio.Event()
- responses = (ListToolsResult(tools=[]), ListPromptsResult(prompts=[]), ListResourcesResult(resources=[]), ListResourceTemplatesResult(resource_templates=[]))
+ responses = (
+ ListToolsResult(tools=[]),
+ ListPromptsResult(prompts=[]),
+ ListResourcesResult(resources=[]),
+ ListResourceTemplatesResult(resource_templates=[]),
+ )
def listing(index):
async def run(*args, **kwargs):
@@ -1277,6 +1452,7 @@ async def test_discovery_concurrent_listings_drain_on_failure_and_cancellation(o
return responses[index]
finally:
closed[index].set()
+
return run
with (
@@ -1285,7 +1461,9 @@ async def test_discovery_concurrent_listings_drain_on_failure_and_cancellation(o
patch.object(operations, "_execute_list_resources", side_effect=listing(2)),
patch.object(operations, "_execute_list_resource_templates", side_effect=listing(3)),
):
- task = asyncio.create_task(GatewayOperations().execute(DiscoverRequest(), prepare_context(UserAPIKeyAuth(user_id="scoped"))))
+ task = asyncio.create_task(
+ GatewayOperations().execute(DiscoverRequest(), prepare_context(UserAPIKeyAuth(user_id="scoped")))
+ )
try:
await asyncio.gather(*(event.wait() for event in ready))
if outcome == "cancel":
@@ -1314,7 +1492,8 @@ async def test_tools_listing_preserves_explicit_spend_log_policy(log_enabled):
listing = AsyncMock(return_value=operations.AggregateToolListing(tools=[], outcomes={}))
with patch.object(operations, "_list_mcp_tools", listing):
result = await operations._execute_handle_list_tools(
- prepare_context(UserAPIKeyAuth(user_id="caller")), PaginatedRequestParams(),
+ prepare_context(UserAPIKeyAuth(user_id="caller")),
+ PaginatedRequestParams(),
log_list_tools_to_spendlogs=log_enabled,
)
assert result.tools == []
@@ -1351,7 +1530,10 @@ async def test_list_mcp_tools_records_the_catalog_only_when_asked(
@pytest.mark.asyncio
async def test_discovery_keeps_one_catalog_revision_across_concurrent_listings(monkeypatch):
from mcp.types import (
- DiscoverRequest, ListToolsResult, ListPromptsResult, ListResourcesResult,
+ DiscoverRequest,
+ ListToolsResult,
+ ListPromptsResult,
+ ListResourcesResult,
ListResourceTemplatesResult,
)
from litellm.proxy import proxy_server
@@ -1365,8 +1547,12 @@ async def test_discovery_keeps_one_catalog_revision_across_concurrent_listings(m
monkeypatch.setattr(proxy_server, "prisma_client", None)
monkeypatch.setattr(operations, "global_mcp_server_manager", manager)
observed = []
- responses = (ListToolsResult(tools=[]), ListPromptsResult(prompts=[]),
- ListResourcesResult(resources=[]), ListResourceTemplatesResult(resource_templates=[]))
+ responses = (
+ ListToolsResult(tools=[]),
+ ListPromptsResult(prompts=[]),
+ ListResourcesResult(resources=[]),
+ ListResourceTemplatesResult(resource_templates=[]),
+ )
def listing(index):
async def run(*args, **kwargs):
@@ -1374,6 +1560,7 @@ async def test_discovery_keeps_one_catalog_revision_across_concurrent_listings(m
observed.append(manager.get_mcp_server_by_id(original.server_id).name)
manager.registry = {updated.server_id: updated}
return responses[index]
+
return run
with (
@@ -1402,8 +1589,12 @@ async def test_local_handler_freshness_tracks_registered_owner_with_overlapping_
monkeypatch.setattr(proxy_server, "prisma_client", None)
manager = MCPServerManager()
now = datetime.now(timezone.utc)
- private = MCPServer(server_id="private", name="billing", alias="billing", transport=MCPTransport.http, updated_at=now)
- allowed = MCPServer(server_id="allowed", name="billing_admin", alias="billing-admin", transport=MCPTransport.http, updated_at=now)
+ private = MCPServer(
+ server_id="private", name="billing", alias="billing", transport=MCPTransport.http, updated_at=now
+ )
+ allowed = MCPServer(
+ server_id="allowed", name="billing_admin", alias="billing-admin", transport=MCPTransport.http, updated_at=now
+ )
manager.config_mcp_servers = {server.server_id: server for server in (private, allowed)}
monkeypatch.setattr(operations, "global_mcp_server_manager", manager)
monkeypatch.setattr(global_mcp_tool_registry, "published_tools", {})
@@ -1411,7 +1602,9 @@ async def test_local_handler_freshness_tracks_registered_owner_with_overlapping_
global_mcp_tool_registry.register_tool("billing-admin-export", "export", {}, handler, server_id=private.server_id)
async with manager.catalog.operation():
- manager.config_mcp_servers[changed_server_id] = manager.config_mcp_servers[changed_server_id].model_copy(update={"updated_at": now + timedelta(seconds=1)})
+ manager.config_mcp_servers[changed_server_id] = manager.config_mcp_servers[changed_server_id].model_copy(
+ update={"updated_at": now + timedelta(seconds=1)}
+ )
if changed_server_id == "private":
with pytest.raises(HTTPException) as denied:
await operations._handle_local_mcp_tool("billing-admin-export", {})
@@ -1540,9 +1733,15 @@ async def test_continuation_preserves_current_authority_unavailable_error(monkey
monkeypatch.setattr(proxy_server, "prisma_client", None)
caller = UserAPIKeyAuth(api_key="sk-owned-key-without-database")
caller.via_virtual_key = True
- request = {"prompts/list": ListPromptsRequest, "resources/list": ListResourcesRequest, "resources/templates/list": ListResourceTemplatesRequest}[method]
+ request = {
+ "prompts/list": ListPromptsRequest,
+ "resources/list": ListResourcesRequest,
+ "resources/templates/list": ListResourceTemplatesRequest,
+ }[method]
with pytest.raises(MCPError, match="Server misconfigured: no database connection"):
- await GatewayOperations().execute(request(params=PaginatedRequestParams(cursor="existing-state")), prepare_context(caller))
+ await GatewayOperations().execute(
+ request(params=PaginatedRequestParams(cursor="existing-state")), prepare_context(caller)
+ )
@pytest.mark.asyncio
@@ -1552,13 +1751,17 @@ async def test_virtual_tool_catalog_rejects_a_cursor_and_preserves_its_complete_
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
- caller = UserAPIKeyAuth(object_permission=LiteLLM_ObjectPermissionTable(object_permission_id="search", mcp_tool_search_enabled=True))
+ caller = UserAPIKeyAuth(
+ object_permission=LiteLLM_ObjectPermissionTable(object_permission_id="search", mcp_tool_search_enabled=True)
+ )
context = prepare_context(caller)
result = await GatewayOperations().execute(ListToolsRequest(), context)
assert result.tools
assert result.next_cursor is None
with pytest.raises(MCPError, match="fresh listing"):
- await GatewayOperations().execute(ListToolsRequest(params=PaginatedRequestParams(cursor="existing-state")), context)
+ await GatewayOperations().execute(
+ ListToolsRequest(params=PaginatedRequestParams(cursor="existing-state")), context
+ )
@pytest.mark.asyncio
@@ -1573,7 +1776,9 @@ async def test_optional_catalog_preserves_revoked_user_error(monkeypatch, reques
monkeypatch.setattr(proxy_server, "general_settings", {"supported_db_objects": []})
table = SimpleNamespace(find_unique=AsyncMock(return_value=None))
- monkeypatch.setattr(proxy_server, "prisma_client", SimpleNamespace(writer_db=SimpleNamespace(litellm_usertable=table)))
+ monkeypatch.setattr(
+ proxy_server, "prisma_client", SimpleNamespace(writer_db=SimpleNamespace(litellm_usertable=table))
+ )
monkeypatch.setattr(proxy_server, "user_api_key_cache", DualCache())
caller = UserAPIKeyAuth(user_id="revoked-catalog-user")
caller.mcp_admitted_user_subject = True
@@ -1603,7 +1808,9 @@ async def test_tool_continuation_failure_requires_a_fresh_listing(monkeypatch: p
@pytest.mark.asyncio
@pytest.mark.parametrize("gateway", [False, True])
-async def test_initial_tool_listing_preserves_legacy_error_fallback(monkeypatch: pytest.MonkeyPatch, gateway: bool) -> None:
+async def test_initial_tool_listing_preserves_legacy_error_fallback(
+ monkeypatch: pytest.MonkeyPatch, gateway: bool
+) -> None:
from mcp.types import ListToolsRequest
fetch: Final = AsyncMock(side_effect=RuntimeError("catalog temporarily unavailable"))
@@ -1624,12 +1831,20 @@ def test_discovery_extra_headers_exclude_gateway_admission_key(header_name: str)
caller_key: Final = "Bearer sk-admission-only"
raw_headers: Final = {"x-litellm-api-key": caller_key, "x-tenant": "tenant-control"}
server: Final = MCPServer(
- server_id="header-boundary", name="header-boundary", transport=MCPTransport.http,
- url="https://example.invalid/mcp", auth_type=MCPAuth.none,
+ server_id="header-boundary",
+ name="header-boundary",
+ transport=MCPTransport.http,
+ url="https://example.invalid/mcp",
+ auth_type=MCPAuth.none,
extra_headers=[header_name, "X-Tenant"],
)
auth_header, extra_headers = operations._prepare_mcp_server_headers(
- server, None, None, None, raw_headers, UserAPIKeyAuth(api_key="sk-admission-only"),
+ server,
+ None,
+ None,
+ None,
+ raw_headers,
+ UserAPIKeyAuth(api_key="sk-admission-only"),
)
assert auth_header is None
assert extra_headers == {"X-Tenant": "tenant-control"}
@@ -1639,17 +1854,27 @@ def test_discovery_extra_headers_exclude_gateway_admission_key(header_name: str)
@pytest.mark.asyncio
@pytest.mark.parametrize("request_kind", ["absent", "non_starlette", "without_bearer", "admission_key"])
async def test_guardrail_context_never_recovers_a_previous_request_bearer(
- _mcp_request_ctx: Callable[..., ServerRequestContext], request_kind: str, monkeypatch: pytest.MonkeyPatch,
+ _mcp_request_ctx: Callable[..., ServerRequestContext],
+ request_kind: str,
+ monkeypatch: pytest.MonkeyPatch,
) -> None:
from mcp.server.auth.middleware.auth_context import auth_context_var
from starlette.requests import Request
from litellm.proxy._experimental.mcp_server import server as gateway
request: Final = (
- None if request_kind == "absent" else object() if request_kind == "non_starlette" else Request({
- "type": "http", "method": "POST", "path": "/mcp",
- "headers": [(b"authorization", b"Bearer sk-current-key")] if request_kind == "admission_key" else [],
- })
+ None
+ if request_kind == "absent"
+ else object()
+ if request_kind == "non_starlette"
+ else Request(
+ {
+ "type": "http",
+ "method": "POST",
+ "path": "/mcp",
+ "headers": [(b"authorization", b"Bearer sk-current-key")] if request_kind == "admission_key" else [],
+ }
+ )
)
previous: Final = auth_context_var.set(None)
try:
@@ -1661,7 +1886,9 @@ async def test_guardrail_context_never_recovers_a_previous_request_bearer(
monkeypatch.setattr(litellm, "callbacks", [capture])
async with gateway._legacy_operation_context(_mcp_request_ctx(request=request), trace=False) as context:
await operations.global_mcp_server_manager.pre_call_tool_check(
- name="echo", arguments={}, server_name="guarded",
+ name="echo",
+ arguments={},
+ server_name="guarded",
user_api_key_auth=context.user_api_key_auth,
proxy_logging_obj=ProxyLogging(user_api_key_cache=UserApiKeyCache()),
server=MCPServer(server_id="guarded", name="guarded", transport=MCPTransport.http, allow_all_keys=True),
diff --git a/tests/unit/proxy/test_proxy_config_unit_test.py b/tests/unit/proxy/test_proxy_config_unit_test.py
index bee3c3a9eaa..28e017bb050 100644
--- a/tests/unit/proxy/test_proxy_config_unit_test.py
+++ b/tests/unit/proxy/test_proxy_config_unit_test.py
@@ -1,4 +1,6 @@
import os
+from pathlib import Path
+from typing import Final
import traceback
from unittest import mock
import pytest
@@ -340,3 +342,16 @@ class TestYamlStorePromptsDbOverride:
proxy_config = ProxyConfig()
assert proxy_config.settings.source("store_prompts_in_spend_logs") == "unset"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("disabled", [["prompt"], "prompts", ["prompts", "tools"]])
+async def test_load_config_rejects_unknown_mcp_disabled_capability(tmp_path: Path, disabled: object) -> None:
+ import yaml
+ from pydantic import ValidationError
+
+ config_path: Final = tmp_path / "config.yaml"
+ config_path.write_text(yaml.safe_dump({"general_settings": {"mcp_disabled_capabilities": disabled}}))
+
+ with pytest.raises(ValidationError):
+ await ProxyConfig().load_config(router=None, config_file_path=str(config_path))
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx
index 4a486bfc648..210e2173457 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx
@@ -147,6 +147,45 @@ describe("MCPNetworkSettings", () => {
expect(updateConfigFieldSetting).not.toHaveBeenCalled();
});
+ it("loads both capability switches on and writes nothing on Save when the proxy reports no stored value", async () => {
+ vi.mocked(getGeneralSettingsCall).mockResolvedValue([
+ { field_name: "mcp_disabled_capabilities", field_value: null },
+ ]);
+ renderSettings();
+ const switches = await screen.findAllByRole("switch");
+ expect(switches.map((s) => s.getAttribute("aria-checked"))).toEqual(["true", "true"]);
+ await userEvent.click(screen.getByRole("button", { name: /Save/ }));
+
+ await waitFor(() => expect(toast.success).toHaveBeenCalledWith("MCP network settings saved"));
+ expect(deleteConfigFieldSetting).not.toHaveBeenCalled();
+ expect(updateConfigFieldSetting).not.toHaveBeenCalled();
+ });
+
+ it("stores the capabilities an admin switches off", async () => {
+ renderSettings();
+ await userEvent.click((await screen.findAllByRole("switch"))[0]);
+ await userEvent.click(screen.getByRole("button", { name: /Save/ }));
+
+ await waitFor(() =>
+ expect(updateConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_disabled_capabilities", ["prompts"]),
+ );
+ expect(deleteConfigFieldSetting).not.toHaveBeenCalled();
+ });
+
+ it("deletes the stored setting when the admin switches the last disabled capability back on", async () => {
+ vi.mocked(getGeneralSettingsCall).mockResolvedValue([
+ { field_name: "mcp_disabled_capabilities", field_value: ["resources"] },
+ ]);
+ renderSettings();
+ const switches = await screen.findAllByRole("switch");
+ expect(switches.map((s) => s.getAttribute("aria-checked"))).toEqual(["true", "false"]);
+ await userEvent.click(switches[1]);
+ await userEvent.click(screen.getByRole("button", { name: /Save/ }));
+
+ await waitFor(() => expect(deleteConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_disabled_capabilities"));
+ expect(updateConfigFieldSetting).not.toHaveBeenCalled();
+ });
+
it("labels the section Allowed Clients and renders each stored client as a card showing alias and value", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{ field_name: "mcp_allowed_clients", field_value: [ANTIGRAVITY, CODEX] },
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx
index c421f1a932e..45180203a58 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx
@@ -13,6 +13,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { toast } from "@/lib/toast";
import {
@@ -159,6 +160,22 @@ const AllowedClientDialog: React.FC
Served Capabilities
++ Turn off prompts or resources when none of your MCP servers offer them. A capability that is turned off is + left out of the initialize response, its list calls return an empty list without contacting any upstream + server, and its get or read calls are rejected. +
+{description}
+