mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
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:
commit
ff2f06e37f
13 changed files with 1392 additions and 22 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
)
|
||||
|
||||
|
|
|
|||
185
litellm/proxy/management_endpoints/mcp_connector_import.py
Normal file
185
litellm/proxy/management_endpoints/mcp_connector_import.py
Normal 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.",
|
||||
)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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." };
|
||||
};
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
117
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
117
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue