Merge pull request #38444 from BerriAI/litellm_mcp_connector_bulk_import

feat(mcp): bulk-import Anthropic MCP connectors via API and admin UI
This commit is contained in:
Mateo Wang 2026-08-29 17:21:57 -07:00 committed by GitHub
commit ff2f06e37f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1392 additions and 22 deletions

View file

@ -600,23 +600,19 @@ async def get_all_mcp_servers(
NULL approval_status predates the approval workflow, so those rows are kept explicitly rather
than dropped by a bare inequality, which SQL evaluates as NULL and would silently hide them.
"""
try:
where: Final[prisma_db_types.LiteLLM_MCPServerTableWhereInput] = (
{"approval_status": approval_status}
if approval_status is not None
# mutable-ok: prisma where-inputs must be plain dicts, and both `NOT` and `not` drop
# NULL rows (measured), so the OR is the only NULL-preserving way to exclude drafts
else {"OR": [{"approval_status": None}, {"approval_status": {"not": MCPApprovalStatus.draft}}]}
)
mcp_servers: Final = await _db_find_mcp_server_rows(prisma_client, where)
where: Final[prisma_db_types.LiteLLM_MCPServerTableWhereInput] = (
{"approval_status": approval_status}
if approval_status is not None
# mutable-ok: prisma where-inputs must be plain dicts, and both `NOT` and `not` drop
# NULL rows (measured), so the OR is the only NULL-preserving way to exclude drafts
else {"OR": [{"approval_status": None}, {"approval_status": {"not": MCPApprovalStatus.draft}}]}
)
mcp_servers: Final = await _db_find_mcp_server_rows(prisma_client, where)
tables: Final = [LiteLLM_MCPServerTable.model_validate(mcp_server.model_dump()) for mcp_server in mcp_servers]
for table in tables:
decrypt_global_env_var_values(table.env_vars)
return tables
except Exception as e:
verbose_proxy_logger.debug("litellm.proxy._experimental.mcp_server.db.py::get_all_mcp_servers - %s", e)
return []
tables: Final = [LiteLLM_MCPServerTable.model_validate(mcp_server.model_dump()) for mcp_server in mcp_servers]
for table in tables:
decrypt_global_env_var_values(table.env_vars)
return tables
async def get_mcp_server(prisma_client: PrismaClient, server_id: str) -> LiteLLM_MCPServerTable | None:

View file

@ -20905,6 +20905,223 @@
"title": "LiteLLM_MCPServerTable",
"type": "object"
},
"MCPConnectorEntry": {
"properties": {
"args": {
"items": {
"type": "string"
},
"title": "Args",
"type": "array"
},
"authorization_token": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization Token"
},
"command": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Command"
},
"description": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Description"
},
"env": {
"additionalProperties": {
"type": "string"
},
"title": "Env",
"type": "object"
},
"headers": {
"anyOf": [
{
"additionalProperties": {
"type": "string"
},
"type": "object"
},
{
"type": "null"
}
],
"title": "Headers"
},
"name": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Name"
},
"type": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Type"
},
"url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Url"
}
},
"title": "MCPConnectorEntry",
"type": "object"
},
"MCPConnectorImportFailure": {
"properties": {
"error": {
"title": "Error",
"type": "string"
},
"name": {
"title": "Name",
"type": "string"
}
},
"required": [
"name",
"error"
],
"title": "MCPConnectorImportFailure",
"type": "object"
},
"MCPConnectorImportRequest": {
"properties": {
"mcp_servers": {
"anyOf": [
{
"additionalProperties": {
"$ref": "#/components/schemas/MCPConnectorEntry"
},
"type": "object"
},
{
"items": {
"$ref": "#/components/schemas/MCPConnectorEntry"
},
"type": "array"
}
],
"title": "Mcp Servers"
}
},
"required": [
"mcp_servers"
],
"title": "MCPConnectorImportRequest",
"type": "object"
},
"MCPConnectorImportResponse": {
"properties": {
"errors": {
"items": {
"$ref": "#/components/schemas/MCPConnectorImportFailure"
},
"title": "Errors",
"type": "array"
},
"imported": {
"items": {
"$ref": "#/components/schemas/MCPConnectorImportResult"
},
"title": "Imported",
"type": "array"
},
"skipped": {
"items": {
"$ref": "#/components/schemas/MCPConnectorImportSkipped"
},
"title": "Skipped",
"type": "array"
}
},
"required": [
"imported",
"skipped",
"errors"
],
"title": "MCPConnectorImportResponse",
"type": "object"
},
"MCPConnectorImportResult": {
"properties": {
"alias": {
"title": "Alias",
"type": "string"
},
"name": {
"title": "Name",
"type": "string"
},
"server_id": {
"title": "Server Id",
"type": "string"
}
},
"required": [
"name",
"server_id",
"alias"
],
"title": "MCPConnectorImportResult",
"type": "object"
},
"MCPConnectorImportSkipped": {
"properties": {
"name": {
"title": "Name",
"type": "string"
},
"reason": {
"title": "Reason",
"type": "string"
}
},
"required": [
"name",
"reason"
],
"title": "MCPConnectorImportSkipped",
"type": "object"
},
"MCPCredentials": {
"properties": {
"audience": {
@ -23144,6 +23361,53 @@
]
}
},
"/v1/mcp/server/import": {
"post": {
"description": "Bulk-import MCP connectors from Anthropic mcpServers or mcp_servers JSON",
"operationId": "import_mcp_servers_v1_mcp_server_import_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MCPConnectorImportRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MCPConnectorImportResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Import Mcp Servers",
"tags": [
"mcp_management"
]
}
},
"/v1/mcp/server/oauth/session": {
"post": {
"description": "Temporarily cache an MCP server in memory without writing to the database",

View file

@ -1406,15 +1406,15 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase):
# BYOM submission fields — set by the endpoint, not by the caller.
# Any caller-provided values are silently overridden before persistence.
approval_status: str | None = Field(
None,
default=None,
description="Server-managed: set by the endpoint; caller values are overridden.",
)
submitted_by: str | None = Field(
None,
default=None,
description="Server-managed: set by the endpoint; caller values are overridden.",
)
submitted_at: datetime | None = Field(
None,
default=None,
description="Server-managed: set by the endpoint; caller values are overridden.",
)

View file

@ -0,0 +1,185 @@
"""
Convert Anthropic MCP connector definitions into LiteLLM MCP server create requests.
Two interchange shapes are accepted:
- the ``mcpServers`` mapping used by Claude Desktop / Claude Code config files
- the ``mcp_servers`` array used by the Anthropic Messages API MCP connector
"""
import re
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Final
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError
from litellm.proxy._types import MCPApprovalStatus, NewMCPServerRequest
from litellm.types.mcp import MCPAuth, MCPAuthType, MCPCredentials, MCPTransport
class MCPConnectorEntry(BaseModel):
model_config = ConfigDict(populate_by_name=True)
name: str | None = None
type: str | None = None
url: str | None = None
authorization_token: str | None = Field(
default=None, validation_alias=AliasChoices("authorization_token", "authorizationToken")
)
headers: Mapping[str, str] | None = None
command: str | None = None
args: tuple[str, ...] = Field(default_factory=tuple)
env: Mapping[str, str] = Field(default_factory=dict)
description: str | None = None
class MCPConnectorImportRequest(BaseModel):
model_config = ConfigDict(populate_by_name=True)
mcp_servers: Mapping[str, MCPConnectorEntry] | tuple[MCPConnectorEntry, ...] = Field(
validation_alias=AliasChoices("mcp_servers", "mcpServers")
)
@dataclass(frozen=True, slots=True)
class ConvertedConnector:
name: str
request: NewMCPServerRequest
@dataclass(frozen=True, slots=True)
class ConnectorConversionError:
name: str
error: str
class MCPConnectorImportResult(BaseModel):
name: str
server_id: str
alias: str
class MCPConnectorImportSkipped(BaseModel):
name: str
reason: str
class MCPConnectorImportFailure(BaseModel):
name: str
error: str
class MCPConnectorImportResponse(BaseModel):
imported: tuple[MCPConnectorImportResult, ...]
skipped: tuple[MCPConnectorImportSkipped, ...]
errors: tuple[MCPConnectorImportFailure, ...]
_INVALID_SERVER_NAME_CHARS: Final = re.compile(r"[^A-Za-z0-9_]")
def sanitize_connector_name(name: str) -> str:
sanitized: Final = re.sub(r"_+", "_", _INVALID_SERVER_NAME_CHARS.sub("_", name.strip())).strip("_")
return sanitized
_SSE_TYPES: Final = frozenset({"sse"})
_URL_TYPES: Final = frozenset({"url", "http", "streamable_http", "streamable-http", "sse", ""})
def _convert_entry(name: str, entry: MCPConnectorEntry) -> ConvertedConnector | ConnectorConversionError:
sanitized_name: Final = sanitize_connector_name(name)
if not sanitized_name:
return ConnectorConversionError(name=name, error="Connector name is empty after sanitization.")
if entry.url and entry.command:
return ConnectorConversionError(name=name, error="Connector cannot have both a url and a command.")
if entry.command:
try:
stdio_request: Final = NewMCPServerRequest(
server_name=sanitized_name,
alias=sanitized_name,
description=entry.description,
approval_status=MCPApprovalStatus.active,
transport=MCPTransport.stdio,
command=entry.command,
args=list(entry.args),
env=dict(entry.env),
)
except ValidationError as e:
return ConnectorConversionError(name=name, error=_first_validation_message(e))
return ConvertedConnector(name=name, request=stdio_request)
if not entry.url:
return ConnectorConversionError(name=name, error="Connector must have either a url or a command.")
entry_type: Final = (entry.type or "").lower()
if entry_type not in _URL_TYPES:
return ConnectorConversionError(name=name, error=f"Unsupported connector type '{entry.type}'.")
transport: Final = MCPTransport.sse if entry_type in _SSE_TYPES else MCPTransport.http
auth: Final = _remote_auth(entry)
try:
remote_request: Final = NewMCPServerRequest(
server_name=sanitized_name,
alias=sanitized_name,
description=entry.description,
approval_status=MCPApprovalStatus.active,
transport=transport,
url=entry.url,
auth_type=auth.auth_type,
credentials=auth.credentials,
static_headers=auth.static_headers,
)
except ValidationError as e:
return ConnectorConversionError(name=name, error=_first_validation_message(e))
return ConvertedConnector(name=name, request=remote_request)
@dataclass(frozen=True, slots=True)
class _RemoteAuth:
auth_type: MCPAuthType
credentials: MCPCredentials | None
static_headers: dict[str, str] | None
_AUTHORIZATION_HEADER: Final = "authorization"
_BEARER_PREFIX: Final = "bearer "
def _remote_auth(entry: MCPConnectorEntry) -> _RemoteAuth:
headers: Final[Mapping[str, str]] = entry.headers or {}
header_value: Final = next((value for key, value in headers.items() if key.lower() == _AUTHORIZATION_HEADER), None)
remaining: Final = {key: value for key, value in headers.items() if key.lower() != _AUTHORIZATION_HEADER} or None
if entry.authorization_token:
return _RemoteAuth(MCPAuth.bearer_token, {"auth_value": entry.authorization_token}, remaining)
if not header_value:
return _RemoteAuth(MCPAuth.none, None, remaining)
if header_value.lower().startswith(_BEARER_PREFIX):
return _RemoteAuth(MCPAuth.bearer_token, {"auth_value": header_value[len(_BEARER_PREFIX) :]}, remaining)
return _RemoteAuth(MCPAuth.authorization, {"auth_value": header_value}, remaining)
def _first_validation_message(error: ValidationError) -> str:
messages: Final = tuple(str(detail.get("msg", "")) for detail in error.errors())
return messages[0] if messages else str(error)
def convert_connector_entries(
payload: MCPConnectorImportRequest,
) -> tuple[ConvertedConnector | ConnectorConversionError, ...]:
servers: Final = payload.mcp_servers
if isinstance(servers, Mapping):
return tuple(_convert_entry(name, entry) for name, entry in servers.items())
return tuple(
_convert_entry(entry.name or "", entry) if entry.name else _named_entry_error(index, entry)
for index, entry in enumerate(servers)
)
def _named_entry_error(index: int, entry: MCPConnectorEntry) -> ConnectorConversionError:
return ConnectorConversionError(
name=entry.url or f"entry {index}",
error="Connector entries in list form must have a name.",
)

View file

@ -133,6 +133,7 @@ if MCP_AVAILABLE:
delete_mcp_server,
delete_user_credential,
delete_user_env_vars,
get_all_mcp_servers,
get_all_mcp_servers_for_user,
get_draft_mcp_server,
get_mcp_server,
@ -199,6 +200,16 @@ if MCP_AVAILABLE:
populate_request_with_path_params,
)
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
from litellm.proxy.management_endpoints.mcp_connector_import import (
ConnectorConversionError,
ConvertedConnector,
MCPConnectorImportFailure,
MCPConnectorImportRequest,
MCPConnectorImportResponse,
MCPConnectorImportResult,
MCPConnectorImportSkipped,
convert_connector_entries,
)
from litellm.proxy.management_helpers.utils import management_endpoint_wrapper
from litellm.types.mcp import (
MCP_ADMIN_CONFIG_CREDENTIAL_KEYS,
@ -1626,6 +1637,116 @@ if MCP_AVAILABLE:
return _redact_mcp_credentials(new_mcp_server)
@router.post(
"/server/import",
description="Bulk-import MCP connectors from Anthropic mcpServers or mcp_servers JSON",
dependencies=(Depends(user_api_key_auth),),
response_model=MCPConnectorImportResponse,
status_code=status.HTTP_200_OK,
)
@management_endpoint_wrapper
async def import_mcp_servers(
payload: MCPConnectorImportRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection
):
"""
Bulk-import MCP connectors. Accepts the Claude Desktop / Claude Code
``mcpServers`` mapping or the Anthropic Messages API ``mcp_servers``
array, creates each entry as a LiteLLM MCP server, and returns
per-entry results so partial imports are visible to the caller.
"""
prisma_client: Final = get_prisma_client_or_throw("Database not connected. Connect a database to your proxy")
if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={ # mutable-ok: FastAPI HTTPException detail requires a plain dict
"error": "User does not have permission to import mcp servers. You can only import mcp servers if you are a PROXY_ADMIN."
},
)
conversions: Final = convert_connector_entries(payload)
existing_servers: Final = await get_all_mcp_servers(prisma_client)
existing_names: Final = frozenset(
name for server in existing_servers for name in (server.alias, server.server_name) if name
)
def _classify(
index: int, conversion: ConvertedConnector | ConnectorConversionError
) -> ConvertedConnector | ConnectorConversionError | MCPConnectorImportSkipped:
if isinstance(conversion, ConnectorConversionError):
return conversion
alias: Final = conversion.request.alias or ""
if alias in existing_names:
return MCPConnectorImportSkipped(
name=conversion.name, reason=f"An MCP server named '{alias}' already exists."
)
earlier_aliases: Final = frozenset(
earlier.request.alias or ""
for earlier in conversions[:index]
if isinstance(earlier, ConvertedConnector)
)
if alias in earlier_aliases:
return MCPConnectorImportSkipped(
name=conversion.name, reason=f"Duplicate connector name '{alias}' in the import payload."
)
return conversion
async def _create(
conversion: ConvertedConnector,
) -> MCPConnectorImportResult | MCPConnectorImportFailure:
try:
validate_and_normalize_mcp_server_payload(conversion.request)
except HTTPException as e:
error_text: Final = (
str(e.detail.get("error", e.detail)) if isinstance(e.detail, dict) else str(e.detail)
)
return MCPConnectorImportFailure(name=conversion.name, error=error_text)
try:
created: Final = await create_mcp_server(
prisma_client,
conversion.request,
touched_by=user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME,
)
except Exception as e: # noqa: BLE001 # any create failure must become a per-entry error, not a 500
verbose_proxy_logger.exception("Error importing mcp server %s: %s", conversion.name, e)
return MCPConnectorImportFailure(name=conversion.name, error=str(e))
try:
await global_mcp_server_manager.add_server(created)
except Exception as e: # noqa: BLE001 # the row is committed; the reload after the loop retries registration
verbose_proxy_logger.exception(
"Imported mcp server %s committed but in-memory registration failed: %s", conversion.name, e
)
return MCPConnectorImportResult(
name=conversion.name, server_id=created.server_id, alias=created.alias or ""
)
classified: Final = tuple(_classify(index, conversion) for index, conversion in enumerate(conversions))
outcomes: Final = tuple(
[
await _create(entry) if isinstance(entry, ConvertedConnector) else entry for entry in classified
] # mutable-ok: await is illegal in a generator expression here
)
imported: Final = tuple(entry for entry in outcomes if isinstance(entry, MCPConnectorImportResult))
if imported:
try:
await global_mcp_server_manager.reload_servers_from_database()
except Exception as e: # noqa: BLE001 # rows are committed; a refresh failure must not surface as a 500
verbose_proxy_logger.exception("MCP connector import committed but registry refresh failed: %s", e)
return MCPConnectorImportResponse(
imported=imported,
skipped=tuple(entry for entry in outcomes if isinstance(entry, MCPConnectorImportSkipped)),
errors=tuple(
MCPConnectorImportFailure(name=entry.name, error=entry.error)
if isinstance(entry, ConnectorConversionError)
else entry
for entry in outcomes
if isinstance(entry, (ConnectorConversionError, MCPConnectorImportFailure))
),
)
@router.post(
"/server/oauth/session",
description="Temporarily cache an MCP server in memory without writing to the database",

View file

@ -0,0 +1,208 @@
import pytest
from litellm.proxy.management_endpoints.mcp_connector_import import (
ConnectorConversionError,
ConvertedConnector,
MCPConnectorImportRequest,
convert_connector_entries,
sanitize_connector_name,
)
from litellm.types.mcp import MCPAuth, MCPTransport
def _single(payload: dict) -> ConvertedConnector | ConnectorConversionError:
results = convert_connector_entries(MCPConnectorImportRequest.model_validate(payload))
assert len(results) == 1
return results[0]
class TestSanitizeConnectorName:
@pytest.mark.parametrize(
"raw,expected",
[
("my-server", "my_server"),
(" spaced name ", "spaced_name"),
("already_ok", "already_ok"),
("a.b.c", "a_b_c"),
("---", ""),
],
)
def test_sanitizes_to_mcp_safe_names(self, raw, expected):
assert sanitize_connector_name(raw) == expected
class TestConvertMcpServersMapping:
def test_url_connector_with_authorization_token(self):
result = _single(
{
"mcpServers": {
"github-mcp": {
"url": "https://api.example.com/mcp",
"authorization_token": "secret-token",
"headers": {"X-Env": "prod"},
"description": "GitHub connector",
}
}
}
)
assert isinstance(result, ConvertedConnector)
assert result.request.server_name == "github_mcp"
assert result.request.alias == "github_mcp"
assert result.request.transport == MCPTransport.http
assert result.request.url == "https://api.example.com/mcp"
assert result.request.auth_type == MCPAuth.bearer_token
assert result.request.credentials == {"auth_value": "secret-token"}
assert result.request.static_headers == {"X-Env": "prod"}
assert result.request.description == "GitHub connector"
def test_authorization_header_becomes_bearer_credentials(self):
result = _single(
{
"mcpServers": {
"srv": {
"url": "https://x.example/mcp",
"headers": {"Authorization": "Bearer header-token", "X-Env": "prod"},
}
}
}
)
assert isinstance(result, ConvertedConnector)
assert result.request.auth_type == MCPAuth.bearer_token
assert result.request.credentials == {"auth_value": "header-token"}
assert result.request.static_headers == {"X-Env": "prod"}
def test_authorization_header_without_bearer_prefix_is_sent_verbatim(self):
result = _single(
{"mcpServers": {"srv": {"url": "https://x.example/mcp", "headers": {"authorization": "raw-token"}}}}
)
assert isinstance(result, ConvertedConnector)
assert result.request.auth_type == MCPAuth.authorization
assert result.request.credentials == {"auth_value": "raw-token"}
assert result.request.static_headers is None
def test_basic_authorization_header_is_sent_verbatim(self):
result = _single(
{"mcpServers": {"srv": {"url": "https://x.example/mcp", "headers": {"Authorization": "Basic dXNlcjpwdw=="}}}}
)
assert isinstance(result, ConvertedConnector)
assert result.request.auth_type == MCPAuth.authorization
assert result.request.credentials == {"auth_value": "Basic dXNlcjpwdw=="}
assert result.request.static_headers is None
def test_authorization_token_wins_over_authorization_header(self):
result = _single(
{
"mcpServers": {
"srv": {
"url": "https://x.example/mcp",
"authorization_token": "explicit-token",
"headers": {"Authorization": "Bearer header-token"},
}
}
}
)
assert isinstance(result, ConvertedConnector)
assert result.request.auth_type == MCPAuth.bearer_token
assert result.request.credentials == {"auth_value": "explicit-token"}
assert result.request.static_headers is None
def test_camel_case_authorization_token_alias(self):
result = _single(
{"mcpServers": {"srv": {"url": "https://x.example/mcp", "authorizationToken": "tok"}}}
)
assert isinstance(result, ConvertedConnector)
assert result.request.credentials == {"auth_value": "tok"}
def test_url_connector_without_token_uses_no_auth(self):
result = _single({"mcpServers": {"open": {"url": "https://open.example/mcp"}}})
assert isinstance(result, ConvertedConnector)
assert result.request.auth_type == MCPAuth.none
assert result.request.credentials is None
def test_sse_type_maps_to_sse_transport(self):
result = _single({"mcpServers": {"legacy": {"type": "sse", "url": "https://sse.example/mcp"}}})
assert isinstance(result, ConvertedConnector)
assert result.request.transport == MCPTransport.sse
def test_stdio_connector(self):
result = _single(
{
"mcpServers": {
"local": {
"command": "npx",
"args": ["-y", "@example/mcp-server"],
"env": {"API_KEY": "value"},
}
}
}
)
assert isinstance(result, ConvertedConnector)
assert result.request.transport == MCPTransport.stdio
assert result.request.command == "npx"
assert result.request.args == ["-y", "@example/mcp-server"]
assert result.request.env == {"API_KEY": "value"}
def test_disallowed_stdio_command_returns_error(self):
result = _single({"mcpServers": {"evil": {"command": "rm", "args": ["-rf", "/"]}}})
assert isinstance(result, ConnectorConversionError)
assert "not in the allowed commands list" in result.error
def test_unsupported_type_returns_error(self):
result = _single({"mcpServers": {"ws": {"type": "websocket", "url": "wss://x.example"}}})
assert isinstance(result, ConnectorConversionError)
assert "Unsupported connector type" in result.error
def test_missing_url_and_command_returns_error(self):
result = _single({"mcpServers": {"empty": {}}})
assert isinstance(result, ConnectorConversionError)
assert "either a url or a command" in result.error
def test_url_and_command_together_returns_error(self):
result = _single({"mcpServers": {"both": {"url": "https://x.example/mcp", "command": "npx"}}})
assert isinstance(result, ConnectorConversionError)
assert "both a url and a command" in result.error
def test_name_empty_after_sanitization_returns_error(self):
result = _single({"mcpServers": {"---": {"url": "https://x.example/mcp"}}})
assert isinstance(result, ConnectorConversionError)
assert "empty after sanitization" in result.error
class TestConvertMcpServersList:
def test_anthropic_messages_api_list_shape(self):
result = _single(
{
"mcp_servers": [
{
"type": "url",
"url": "https://mcp.example.com/sse",
"name": "deepwiki",
"authorization_token": "tok",
}
]
}
)
assert isinstance(result, ConvertedConnector)
assert result.request.server_name == "deepwiki"
assert result.request.transport == MCPTransport.http
assert result.request.credentials == {"auth_value": "tok"}
def test_list_entry_without_name_returns_error(self):
result = _single({"mcp_servers": [{"type": "url", "url": "https://x.example/mcp"}]})
assert isinstance(result, ConnectorConversionError)
assert "must have a name" in result.error
def test_partial_conversion_preserves_per_entry_results(self):
results = convert_connector_entries(
MCPConnectorImportRequest.model_validate(
{
"mcpServers": {
"good": {"url": "https://good.example/mcp"},
"bad": {"type": "websocket", "url": "wss://bad.example"},
}
}
)
)
assert len(results) == 2
assert isinstance(results[0], ConvertedConnector)
assert isinstance(results[1], ConnectorConversionError)

View file

@ -1960,6 +1960,20 @@ class TestTemporaryMCPSessionEndpoints:
where = find_rows.await_args.args[1]
assert where == {"OR": [{"approval_status": None}, {"approval_status": {"not": "draft"}}]}
@pytest.mark.asyncio
async def test_get_all_mcp_servers_propagates_read_failures(self):
"""Regression: a swallowed read failure returned [] and silently disabled the bulk-import
dedupe, so a flaky DB read turned a re-import into duplicate servers."""
from litellm.proxy._experimental.mcp_server.db import get_all_mcp_servers
find_rows = AsyncMock(side_effect=RuntimeError("db down"))
with patch( # test-quality-ok: the helper takes its row reader from module scope, matching the suite's pattern
"litellm.proxy._experimental.mcp_server.db._db_find_mcp_server_rows",
find_rows,
):
with pytest.raises(RuntimeError, match="db down"):
await get_all_mcp_servers(MagicMock())
@pytest.mark.asyncio
async def test_resolve_session_server_id_refuses_an_unknown_caller_supplied_id(self):
"""Regression: two concurrent sessions must never land on one id.
@ -6786,3 +6800,174 @@ class TestConnectedAppViewAnnotation:
assert all(server.connected_app_reachable is None for server in result)
reload_mock.assert_not_awaited()
class TestImportMCPServers:
"""Bulk connector import must be admin-only and report per-entry outcomes."""
@staticmethod
def _import_patches(existing_servers, create_mock, mock_manager):
return (
patch( # test-quality-ok: endpoint takes collaborators from module scope, matching the suite's pattern
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=MagicMock(),
),
patch( # test-quality-ok: endpoint takes collaborators from module scope, matching the suite's pattern
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_all_mcp_servers",
AsyncMock(return_value=existing_servers),
),
patch( # test-quality-ok: endpoint takes collaborators from module scope, matching the suite's pattern
"litellm.proxy.management_endpoints.mcp_management_endpoints.create_mcp_server",
create_mock,
),
patch( # test-quality-ok: endpoint takes collaborators from module scope, matching the suite's pattern
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
mock_manager,
),
)
@pytest.mark.asyncio
async def test_non_admin_is_rejected(self):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
MCPConnectorImportRequest,
import_mcp_servers,
)
payload = MCPConnectorImportRequest.model_validate(
{"mcpServers": {"srv": {"url": "https://x.example/mcp"}}}
)
caller = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.INTERNAL_USER)
with patch( # test-quality-ok: endpoint takes collaborators from module scope, matching the suite's pattern
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=MagicMock(),
):
with pytest.raises(HTTPException) as exc_info:
await import_mcp_servers(payload=payload, user_api_key_dict=caller)
assert exc_info.value.status_code == 403
@pytest.mark.asyncio
async def test_import_reports_imported_skipped_and_errors(self):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
MCPConnectorImportRequest,
import_mcp_servers,
)
payload = MCPConnectorImportRequest.model_validate(
{
"mcpServers": {
"new-server": {"url": "https://new.example/mcp", "authorization_token": "tok"},
"existing": {"url": "https://existing.example/mcp"},
"broken": {"type": "websocket", "url": "wss://x.example"},
}
}
)
admin = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-user")
existing = generate_mock_mcp_server_db_record(server_id="existing-1", alias="existing")
created = generate_mock_mcp_server_db_record(server_id="created-1", alias="new_server")
create_mock = AsyncMock(return_value=created)
mock_manager = MagicMock()
mock_manager.reload_servers_from_database = AsyncMock()
mock_manager.add_server = AsyncMock()
with ExitStack() as stack:
for p in self._import_patches([existing], create_mock, mock_manager):
stack.enter_context(p)
result = await import_mcp_servers(payload=payload, user_api_key_dict=admin)
assert [entry.name for entry in result.imported] == ["new-server"]
assert result.imported[0].server_id == "created-1"
assert [entry.name for entry in result.skipped] == ["existing"]
assert "already exists" in result.skipped[0].reason
assert [entry.name for entry in result.errors] == ["broken"]
create_mock.assert_awaited_once()
sent_request = create_mock.await_args[0][1]
assert sent_request.credentials == {"auth_value": "tok"}
mock_manager.add_server.assert_awaited_once_with(created)
mock_manager.reload_servers_from_database.assert_awaited_once()
@pytest.mark.asyncio
async def test_duplicate_names_within_payload_are_skipped(self):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
MCPConnectorImportRequest,
import_mcp_servers,
)
payload = MCPConnectorImportRequest.model_validate(
{
"mcp_servers": [
{"type": "url", "url": "https://a.example/mcp", "name": "dup srv"},
{"type": "url", "url": "https://b.example/mcp", "name": "dup-srv"},
]
}
)
admin = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-user")
created = generate_mock_mcp_server_db_record(server_id="created-1", alias="dup_srv")
create_mock = AsyncMock(return_value=created)
mock_manager = MagicMock()
mock_manager.reload_servers_from_database = AsyncMock()
mock_manager.add_server = AsyncMock()
with ExitStack() as stack:
for p in self._import_patches([], create_mock, mock_manager):
stack.enter_context(p)
result = await import_mcp_servers(payload=payload, user_api_key_dict=admin)
assert len(result.imported) == 1
assert len(result.skipped) == 1
assert "Duplicate connector name" in result.skipped[0].reason
create_mock.assert_awaited_once()
mock_manager.add_server.assert_awaited_once_with(created)
@pytest.mark.asyncio
async def test_no_imports_skips_registry_refresh(self):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
MCPConnectorImportRequest,
import_mcp_servers,
)
payload = MCPConnectorImportRequest.model_validate(
{"mcpServers": {"existing": {"url": "https://existing.example/mcp"}}}
)
admin = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-user")
existing = generate_mock_mcp_server_db_record(server_id="existing-1", alias="existing")
create_mock = AsyncMock()
mock_manager = MagicMock()
mock_manager.reload_servers_from_database = AsyncMock()
mock_manager.add_server = AsyncMock()
with ExitStack() as stack:
for p in self._import_patches([existing], create_mock, mock_manager):
stack.enter_context(p)
result = await import_mcp_servers(payload=payload, user_api_key_dict=admin)
assert result.imported == ()
create_mock.assert_not_awaited()
mock_manager.add_server.assert_not_awaited()
mock_manager.reload_servers_from_database.assert_not_awaited()
@pytest.mark.asyncio
async def test_registration_failure_keeps_the_import_result(self):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
MCPConnectorImportRequest,
import_mcp_servers,
)
payload = MCPConnectorImportRequest.model_validate(
{"mcpServers": {"new-server": {"url": "https://new.example/mcp"}}}
)
admin = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-user")
created = generate_mock_mcp_server_db_record(server_id="created-1", alias="new_server")
create_mock = AsyncMock(return_value=created)
mock_manager = MagicMock()
mock_manager.reload_servers_from_database = AsyncMock()
mock_manager.add_server = AsyncMock(side_effect=RuntimeError("registration boom"))
with ExitStack() as stack:
for p in self._import_patches([], create_mock, mock_manager):
stack.enter_context(p)
result = await import_mcp_servers(payload=payload, user_api_key_dict=admin)
assert [entry.name for entry in result.imported] == ["new-server"]
mock_manager.reload_servers_from_database.assert_awaited_once()

View file

@ -0,0 +1,134 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Alert, AlertTitle } from "@/components/shared/Alert";
import { importMCPServers } from "@/components/networking";
import { toast } from "@/lib/toast";
import { MCPConnectorImportResponse, parseConnectorConfig } from "./importConnectorConfig";
interface ImportMCPServersProps {
accessToken: string;
open: boolean;
onClose: () => void;
onImported: () => void;
}
const PLACEHOLDER = `{
"mcpServers": {
"my_server": {
"url": "https://example.com/mcp",
"authorization_token": "..."
}
}
}`;
const ImportMCPServers: React.FC<ImportMCPServersProps> = ({ accessToken, open, onClose, onImported }) => {
const [configText, setConfigText] = useState("");
const [parseError, setParseError] = useState<string | null>(null);
const [isImporting, setIsImporting] = useState(false);
const [result, setResult] = useState<MCPConnectorImportResponse | null>(null);
const handleClose = () => {
setConfigText("");
setParseError(null);
setResult(null);
onClose();
};
const handleImport = async () => {
const parsed = parseConnectorConfig(configText);
if (!parsed.ok) {
setParseError(parsed.error);
return;
}
setParseError(null);
setIsImporting(true);
try {
const response = (await importMCPServers(accessToken, parsed.payload)) as MCPConnectorImportResponse;
setResult(response);
if (response.imported.length > 0) {
toast.success(`Imported ${response.imported.length} MCP server${response.imported.length === 1 ? "" : "s"}`);
onImported();
}
} catch (error) {
console.error("Failed to import MCP servers:", error);
setParseError("Import request failed. Check the proxy logs for details.");
} finally {
setIsImporting(false);
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Import MCP Connectors</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Paste an Anthropic connector configuration: the <code>mcpServers</code> mapping from a Claude Desktop /
Claude Code config file, or the <code>mcp_servers</code> array from the Anthropic Messages API.
</p>
<Textarea
aria-label="Connector JSON"
value={configText}
onChange={(e) => setConfigText(e.target.value)}
placeholder={PLACEHOLDER}
rows={10}
className="font-mono text-xs"
/>
{parseError && (
<Alert variant="destructive">
<AlertTitle>{parseError}</AlertTitle>
</Alert>
)}
{result && (
<div className="space-y-2 text-sm">
{result.imported.length > 0 && (
<div>
<span className="font-semibold">Imported:</span>{" "}
{result.imported.map((entry) => entry.alias || entry.name).join(", ")}
</div>
)}
{result.skipped.length > 0 && (
<div>
<span className="font-semibold">Skipped:</span>
<ul className="ml-4 list-disc">
{result.skipped.map((entry) => (
<li key={entry.name}>
{entry.name}: {entry.reason}
</li>
))}
</ul>
</div>
)}
{result.errors.length > 0 && (
<div>
<span className="font-semibold">Failed:</span>
<ul className="ml-4 list-disc">
{result.errors.map((entry) => (
<li key={entry.name}>
{entry.name}: {entry.error}
</li>
))}
</ul>
</div>
)}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleClose} disabled={isImporting}>
Close
</Button>
<Button onClick={handleImport} disabled={isImporting}>
{isImporting ? "Importing..." : "Import"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};
export default ImportMCPServers;

View file

@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { parseConnectorConfig } from "./importConnectorConfig";
describe("parseConnectorConfig", () => {
it("accepts a Claude Desktop mcpServers mapping", () => {
const result = parseConnectorConfig(
JSON.stringify({
mcpServers: {
github: { url: "https://api.example.com/mcp", authorization_token: "tok" },
local: { command: "npx", args: ["-y", "@example/server"] },
},
}),
);
expect(result).toEqual({
ok: true,
payload: {
mcpServers: {
github: { url: "https://api.example.com/mcp", authorization_token: "tok" },
local: { command: "npx", args: ["-y", "@example/server"] },
},
},
connectorCount: 2,
});
});
it("accepts an Anthropic Messages API mcp_servers array", () => {
const result = parseConnectorConfig(
JSON.stringify({
mcp_servers: [{ type: "url", url: "https://mcp.example.com/sse", name: "deepwiki" }],
}),
);
expect(result).toEqual({
ok: true,
payload: { mcp_servers: [{ type: "url", url: "https://mcp.example.com/sse", name: "deepwiki" }] },
connectorCount: 1,
});
});
it("rejects empty input", () => {
const result = parseConnectorConfig(" ");
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain("Paste your connector JSON");
});
it("rejects malformed JSON", () => {
const result = parseConnectorConfig("{ not json");
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain("Invalid JSON");
});
it("rejects objects without a recognized key", () => {
const result = parseConnectorConfig(JSON.stringify({ servers: {} }));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain("mcpServers or mcp_servers");
});
it("rejects an empty mcpServers mapping", () => {
const result = parseConnectorConfig(JSON.stringify({ mcpServers: {} }));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain("no connectors");
});
it("rejects a non-array mcp_servers", () => {
const result = parseConnectorConfig(JSON.stringify({ mcp_servers: { a: 1 } }));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain("must be an array");
});
it("rejects an array mcpServers", () => {
const result = parseConnectorConfig(JSON.stringify({ mcpServers: [] }));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain("must be an object");
});
});

View file

@ -0,0 +1,64 @@
export interface MCPConnectorImportResult {
name: string;
server_id: string;
alias: string;
}
export interface MCPConnectorImportSkipped {
name: string;
reason: string;
}
export interface MCPConnectorImportFailure {
name: string;
error: string;
}
export interface MCPConnectorImportResponse {
imported: MCPConnectorImportResult[];
skipped: MCPConnectorImportSkipped[];
errors: MCPConnectorImportFailure[];
}
export type ParsedConnectorConfig =
| { ok: true; payload: Record<string, unknown>; connectorCount: number }
| { ok: false; error: string };
export const parseConnectorConfig = (text: string): ParsedConnectorConfig => {
const trimmed = text.trim();
if (!trimmed) {
return { ok: false, error: "Paste your connector JSON before importing." };
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return { ok: false, error: "Invalid JSON. Check for missing quotes, commas, or brackets." };
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return { ok: false, error: "Expected a JSON object with an mcpServers or mcp_servers key." };
}
const record = parsed as Record<string, unknown>;
const mapping = record.mcpServers;
if (mapping !== undefined) {
if (typeof mapping !== "object" || mapping === null || Array.isArray(mapping)) {
return { ok: false, error: "mcpServers must be an object mapping connector names to definitions." };
}
const connectorCount = Object.keys(mapping).length;
if (connectorCount === 0) {
return { ok: false, error: "mcpServers contains no connectors." };
}
return { ok: true, payload: { mcpServers: mapping }, connectorCount };
}
const list = record.mcp_servers;
if (list !== undefined) {
if (!Array.isArray(list)) {
return { ok: false, error: "mcp_servers must be an array of connector definitions." };
}
if (list.length === 0) {
return { ok: false, error: "mcp_servers contains no connectors." };
}
return { ok: true, payload: { mcp_servers: list }, connectorCount: list.length };
}
return { ok: false, error: "Expected a JSON object with an mcpServers or mcp_servers key." };
};

View file

@ -24,6 +24,7 @@ import { deleteMCPServer } from "@/components/networking";
import { MCPSubmissionsTab } from "./MCPSubmissionsTab";
import { MCPToolsetsTab } from "./MCPToolsetsTab";
import CreateMCPServer from "./CreateMCPServer";
import ImportMCPServers from "./ImportMCPServers";
import MCPConnect from "./mcp_connect";
import MCPServerCard from "./MCPServerCard";
import { MCPServerView } from "./mcp_server_view";
@ -148,6 +149,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
const [filteredServers, setFilteredServers] = useState<MCPServer[]>([]);
const [isModalVisible, setModalVisible] = useState(false);
const [isDiscoveryVisible, setDiscoveryVisible] = useState(false);
const [isImportVisible, setImportVisible] = useState(false);
const [prefillData, setPrefillData] = useState<DiscoverableMCPServer | null>(null);
const [isDeletingServer, setIsDeletingServer] = useState(false);
const [byokModalServer, setByokModalServer] = useState<MCPServer | null>(null);
@ -482,9 +484,14 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
</div>
<div className="flex items-center gap-2">
{isAdminRole(userRole) && (
<Button className="shrink-0" onClick={() => setDiscoveryVisible(true)}>
+ Add New MCP Server
</Button>
<>
<Button className="shrink-0" variant="secondary" onClick={() => setImportVisible(true)}>
Import from JSON
</Button>
<Button className="shrink-0" onClick={() => setDiscoveryVisible(true)}>
+ Add New MCP Server
</Button>
</>
)}
{!isAdminRole(userRole) && (
<Button
@ -500,6 +507,12 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
)}
</div>
</div>
<ImportMCPServers
accessToken={accessToken}
open={isImportVisible}
onClose={() => setImportVisible(false)}
onImported={() => refetch()}
/>
<MCPDiscovery
isVisible={isDiscoveryVisible}
onClose={() => setDiscoveryVisible(false)}

View file

@ -4991,6 +4991,15 @@ export const createMCPServer = async (
}
};
export const importMCPServers = async (accessToken: string, payload: Record<string, unknown>) => {
try {
return await apiClient.post(`/v1/mcp/server/import`, { accessToken, body: payload });
} catch (error) {
console.error("Failed to import MCP servers:", error);
throw error;
}
};
export const updateMCPServer = async (accessToken: string, formValues: Record<string, any>) => {
try {
return await apiClient.put(`/v1/mcp/server`, { accessToken, body: formValues });

View file

@ -18192,6 +18192,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/mcp/server/import": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Import Mcp Servers
* @description Bulk-import MCP connectors from Anthropic mcpServers or mcp_servers JSON
*/
post: operations["import_mcp_servers_v1_mcp_server_import_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/mcp/server/oauth/session": {
parameters: {
query?: never;
@ -30565,6 +30585,70 @@ export interface components {
*/
status?: "healthy" | "unhealthy";
};
/** MCPConnectorEntry */
MCPConnectorEntry: {
/** Args */
args?: string[];
/** Authorization Token */
authorization_token?: string | null;
/** Command */
command?: string | null;
/** Description */
description?: string | null;
/** Env */
env?: {
[key: string]: string;
};
/** Headers */
headers?: {
[key: string]: string;
} | null;
/** Name */
name?: string | null;
/** Type */
type?: string | null;
/** Url */
url?: string | null;
};
/** MCPConnectorImportFailure */
MCPConnectorImportFailure: {
/** Error */
error: string;
/** Name */
name: string;
};
/** MCPConnectorImportRequest */
MCPConnectorImportRequest: {
/** Mcp Servers */
mcp_servers: {
[key: string]: components["schemas"]["MCPConnectorEntry"];
} | components["schemas"]["MCPConnectorEntry"][];
};
/** MCPConnectorImportResponse */
MCPConnectorImportResponse: {
/** Errors */
errors: components["schemas"]["MCPConnectorImportFailure"][];
/** Imported */
imported: components["schemas"]["MCPConnectorImportResult"][];
/** Skipped */
skipped: components["schemas"]["MCPConnectorImportSkipped"][];
};
/** MCPConnectorImportResult */
MCPConnectorImportResult: {
/** Alias */
alias: string;
/** Name */
name: string;
/** Server Id */
server_id: string;
};
/** MCPConnectorImportSkipped */
MCPConnectorImportSkipped: {
/** Name */
name: string;
/** Reason */
reason: string;
};
/** MCPCredentials */
MCPCredentials: {
/** Audience */
@ -61467,6 +61551,39 @@ export interface operations {
};
};
};
import_mcp_servers_v1_mcp_server_import_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["MCPConnectorImportRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MCPConnectorImportResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
add_session_mcp_server_v1_mcp_server_oauth_session_post: {
parameters: {
query?: never;