From f81408535447db7cf6be81caa48c1447965f2288 Mon Sep 17 00:00:00 2001 From: yassin Date: Thu, 27 Aug 2026 01:34:33 +0000 Subject: [PATCH 1/4] feat(mcp): bulk-import Anthropic MCP connectors via API and admin UI Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_lazy_openapi_snapshot.json | 1147 ++++++++++++++++- .../mcp_connector_import.py | 168 +++ .../mcp_management_endpoints.py | 117 ++ .../test_mcp_connector_import.py | 157 +++ .../test_mcp_management_endpoints.py | 140 ++ .../_components/ImportMCPServers.tsx | 134 ++ .../_components/importConnectorConfig.test.ts | 74 ++ .../_components/importConnectorConfig.ts | 64 + .../mcp-servers/_components/mcp_servers.tsx | 19 +- .../src/components/networking.tsx | 9 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 486 ++++++- 11 files changed, 2506 insertions(+), 9 deletions(-) create mode 100644 litellm/proxy/management_endpoints/mcp_connector_import.py create mode 100644 tests/test_litellm/proxy/management_endpoints/test_mcp_connector_import.py create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ImportMCPServers.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/importConnectorConfig.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/importConnectorConfig.ts diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 0e3cbbfd560..5e0b5ba3faf 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -14727,6 +14727,17 @@ "title": "Args", "type": "array" }, + "audience": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Audience" + }, "auth_type": { "anyOf": [ { @@ -14738,7 +14749,11 @@ "authorization", "oauth2", "aws_sigv4", - "token" + "token", + "oauth2_token_exchange", + "oauth2_id_jag", + "true_passthrough", + "oauth_delegate" ], "type": "string" }, @@ -14793,6 +14808,17 @@ ], "title": "Command" }, + "connected_app_reachable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Connected App Reachable" + }, "created_at": { "anyOf": [ { @@ -14826,6 +14852,22 @@ } ] }, + "dcr_bridge": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Dcr Bridge" + }, + "delegate_auth_to_upstream": { + "default": false, + "title": "Delegate Auth To Upstream", + "type": "boolean" + }, "description": { "anyOf": [ { @@ -14844,6 +14886,20 @@ "title": "Env", "type": "object" }, + "env_vars": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/MCPEnvVar" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Env Vars" + }, "extra_headers": { "items": { "type": "string" @@ -14889,6 +14945,17 @@ "title": "Is Byok", "type": "boolean" }, + "issuer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Issuer" + }, "last_health_check": { "anyOf": [ { @@ -14901,6 +14968,17 @@ ], "title": "Last Health Check" }, + "max_concurrent_requests": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Max Concurrent Requests" + }, "mcp_access_groups": { "items": { "type": "string" @@ -14920,6 +14998,26 @@ ], "title": "Mcp Info" }, + "oauth2_flow": { + "anyOf": [ + { + "enum": [ + "client_credentials", + "authorization_code" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Oauth2 Flow" + }, + "oauth_passthrough": { + "default": false, + "title": "Oauth Passthrough", + "type": "boolean" + }, "registration_url": { "anyOf": [ { @@ -15023,6 +15121,17 @@ "description": "Health status: 'healthy', 'unhealthy', 'unknown'", "title": "Status" }, + "subject_token_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Subject Token Type" + }, "submitted_at": { "anyOf": [ { @@ -15063,6 +15172,39 @@ "title": "Teams", "type": "array" }, + "timeout": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Timeout" + }, + "token_exchange_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Token Exchange Endpoint" + }, + "token_exchange_profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Token Exchange Profile" + }, "token_url": { "anyOf": [ { @@ -15153,8 +15295,236 @@ "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": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Audience" + }, "auth_value": { "anyOf": [ { @@ -15243,6 +15613,17 @@ ], "title": "Aws Session Token" }, + "client_assertion_signing_alg": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Client Assertion Signing Alg" + }, "client_id": { "anyOf": [ { @@ -15254,6 +15635,28 @@ ], "title": "Client Id" }, + "client_private_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Client Private Key" + }, + "client_private_key_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Client Private Key Id" + }, "client_secret": { "anyOf": [ { @@ -15265,6 +15668,42 @@ ], "title": "Client Secret" }, + "id_jag_resource": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Id Jag Resource" + }, + "id_jag_resource_token_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Id Jag Resource Token Endpoint" + }, + "redirect_uris": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Redirect Uris" + }, "scopes": { "anyOf": [ { @@ -15278,11 +15717,113 @@ } ], "title": "Scopes" + }, + "subject_token_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Subject Token Type" + }, + "token_endpoint_auth_method": { + "anyOf": [ + { + "enum": [ + "client_secret_basic", + "client_secret_post" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Token Endpoint Auth Method" + }, + "token_exchange_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Token Exchange Endpoint" + }, + "token_exchange_profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Token Exchange Profile" + }, + "upstream_resource": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Upstream Resource" } }, "title": "MCPCredentials", "type": "object" }, + "MCPEnvVar": { + "description": "One environment variable for an MCP server.\n\nVariables can be interpolated into ``static_headers`` using ``${NAME}``\nsyntax. ``scope=global`` values are stored on the server. ``scope=user``\nvalues are stored per-user in ``LiteLLM_MCPUserEnvVars`` and supplied by\neach user.", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "name": { + "title": "Name", + "type": "string" + }, + "scope": { + "$ref": "#/components/schemas/MCPEnvVarScope", + "default": "global" + }, + "value": { + "default": "", + "title": "Value", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "MCPEnvVar", + "type": "object" + }, + "MCPEnvVarScope": { + "description": "Scope for an MCP server environment variable.\n\n- ``global``: value is provided by the admin and used for all users.\n- ``user``: each user must provide their own value via the per-user\n env-var endpoint. The admin-supplied ``value`` is treated as a\n placeholder/hint and is not used at request time.", + "enum": [ + "global", + "user" + ], + "title": "MCPEnvVarScope", + "type": "string" + }, "MCPOAuthUserCredentialRequest": { "description": "Stores a user's OAuth2 token for an OpenAPI MCP server.", "properties": { @@ -15537,6 +16078,112 @@ "title": "MCPUserCredentialResponse", "type": "object" }, + "MCPUserEnvVarSpec": { + "description": "Describes one per-user env var slot for the calling user.\n\nStored values are write-only: the status only reports whether a value\n``is_set`` and never echoes the decrypted secret back to the client.", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "is_set": { + "default": false, + "title": "Is Set", + "type": "boolean" + }, + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "MCPUserEnvVarSpec", + "type": "object" + }, + "MCPUserEnvVarsRequest": { + "description": "Payload for storing the calling user's per-user env var values.", + "properties": { + "values": { + "additionalProperties": { + "type": "string" + }, + "title": "Values", + "type": "object" + } + }, + "required": [ + "values" + ], + "title": "MCPUserEnvVarsRequest", + "type": "object" + }, + "MCPUserEnvVarsStatus": { + "description": "Per-user env var status for a single MCP server.", + "properties": { + "alias": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Alias" + }, + "missing_count": { + "default": 0, + "title": "Missing Count", + "type": "integer" + }, + "required": { + "items": { + "$ref": "#/components/schemas/MCPUserEnvVarSpec" + }, + "title": "Required", + "type": "array" + }, + "server_id": { + "title": "Server Id", + "type": "string" + }, + "server_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Server Name" + }, + "setup_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Setup Url" + } + }, + "required": [ + "server_id" + ], + "title": "MCPUserEnvVarsStatus", + "type": "object" + }, "MakeMCPServersPublicRequest": { "properties": { "mcp_server_ids": { @@ -15604,6 +16251,17 @@ "title": "Args", "type": "array" }, + "audience": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Audience" + }, "auth_type": { "anyOf": [ { @@ -15615,7 +16273,11 @@ "authorization", "oauth2", "aws_sigv4", - "token" + "token", + "oauth2_token_exchange", + "oauth2_id_jag", + "true_passthrough", + "oauth_delegate" ], "type": "string" }, @@ -15680,6 +16342,22 @@ } ] }, + "dcr_bridge": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Dcr Bridge" + }, + "delegate_auth_to_upstream": { + "default": false, + "title": "Delegate Auth To Upstream", + "type": "boolean" + }, "description": { "anyOf": [ { @@ -15698,6 +16376,20 @@ "title": "Env", "type": "object" }, + "env_vars": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/MCPEnvVar" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Env Vars" + }, "extra_headers": { "anyOf": [ { @@ -15728,6 +16420,28 @@ "title": "Is Byok", "type": "boolean" }, + "issuer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Issuer" + }, + "max_concurrent_requests": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Max Concurrent Requests" + }, "mcp_access_groups": { "items": { "type": "string" @@ -15762,6 +16476,11 @@ ], "title": "Oauth2 Flow" }, + "oauth_passthrough": { + "default": false, + "title": "Oauth Passthrough", + "type": "boolean" + }, "registration_url": { "anyOf": [ { @@ -15831,6 +16550,17 @@ ], "title": "Static Headers" }, + "subject_token_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Subject Token Type" + }, "submitted_at": { "anyOf": [ { @@ -15856,6 +16586,39 @@ "description": "Server-managed: set by the endpoint; caller values are overridden.", "title": "Submitted By" }, + "timeout": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Timeout" + }, + "token_exchange_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Token Exchange Endpoint" + }, + "token_exchange_profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Token Exchange Profile" + }, "token_url": { "anyOf": [ { @@ -16008,6 +16771,17 @@ "title": "Args", "type": "array" }, + "audience": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Audience" + }, "auth_type": { "anyOf": [ { @@ -16019,7 +16793,11 @@ "authorization", "oauth2", "aws_sigv4", - "token" + "token", + "oauth2_token_exchange", + "oauth2_id_jag", + "true_passthrough", + "oauth_delegate" ], "type": "string" }, @@ -16084,6 +16862,22 @@ } ] }, + "dcr_bridge": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Dcr Bridge" + }, + "delegate_auth_to_upstream": { + "default": false, + "title": "Delegate Auth To Upstream", + "type": "boolean" + }, "description": { "anyOf": [ { @@ -16102,6 +16896,20 @@ "title": "Env", "type": "object" }, + "env_vars": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/MCPEnvVar" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Env Vars" + }, "extra_headers": { "anyOf": [ { @@ -16132,6 +16940,28 @@ "title": "Is Byok", "type": "boolean" }, + "issuer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Issuer" + }, + "max_concurrent_requests": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Max Concurrent Requests" + }, "mcp_access_groups": { "items": { "type": "string" @@ -16151,6 +16981,26 @@ ], "title": "Mcp Info" }, + "oauth2_flow": { + "anyOf": [ + { + "enum": [ + "client_credentials", + "authorization_code" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Oauth2 Flow" + }, + "oauth_passthrough": { + "default": false, + "title": "Oauth Passthrough", + "type": "boolean" + }, "registration_url": { "anyOf": [ { @@ -16213,6 +17063,50 @@ ], "title": "Static Headers" }, + "subject_token_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Subject Token Type" + }, + "timeout": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Timeout" + }, + "token_exchange_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Token Exchange Endpoint" + }, + "token_exchange_profile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Token Exchange Profile" + }, "token_url": { "anyOf": [ { @@ -16331,6 +17225,13 @@ }, "ValidationError": { "properties": { + "ctx": { + "title": "Context", + "type": "object" + }, + "input": { + "title": "Input" + }, "loc": { "items": { "anyOf": [ @@ -16600,6 +17501,18 @@ "description": "Filter MCP servers by team scope. When provided, returns only servers the team has access to plus globally available (allow_all_keys) servers. Used by the Create Key UI to show team-scoped MCP servers.", "title": "Team Id" } + }, + { + "description": "Annotate each returned server with connected_app_reachable: whether a connected app authorized by the calling user (a gateway OAuth session) is served this server on the aggregate MCP endpoint.", + "in": "query", + "name": "connected_app_view", + "required": false, + "schema": { + "default": false, + "description": "Annotate each returned server with connected_app_reachable: whether a connected app authorized by the calling user (a gateway OAuth session) is served this server on the aggregate MCP endpoint.", + "title": "Connected App View", + "type": "boolean" + } } ], "responses": { @@ -16827,6 +17740,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", @@ -17438,6 +18398,156 @@ ] } }, + "/v1/mcp/server/{server_id}/user-env-vars": { + "delete": { + "description": "Clear the calling user's per-user MCP env var values for this server.", + "operationId": "clear_mcp_user_env_vars_v1_mcp_server__server_id__user_env_vars_delete", + "parameters": [ + { + "in": "path", + "name": "server_id", + "required": true, + "schema": { + "title": "Server Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MCPUserEnvVarsStatus" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Clear Mcp User Env Vars", + "tags": [ + "mcp_management" + ] + }, + "get": { + "description": "Return the calling user's per-user MCP env var status for this server.", + "operationId": "get_mcp_user_env_vars_v1_mcp_server__server_id__user_env_vars_get", + "parameters": [ + { + "in": "path", + "name": "server_id", + "required": true, + "schema": { + "title": "Server Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MCPUserEnvVarsStatus" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Get Mcp User Env Vars", + "tags": [ + "mcp_management" + ] + }, + "post": { + "description": "Store the calling user's per-user MCP env var values for this server. Submitted values are merged over any previously stored values, so you only send the fields you want to set or change; a variable omitted (or sent empty) keeps its stored value. Use DELETE to clear all stored values.", + "operationId": "store_mcp_user_env_vars_v1_mcp_server__server_id__user_env_vars_post", + "parameters": [ + { + "in": "path", + "name": "server_id", + "required": true, + "schema": { + "title": "Server Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MCPUserEnvVarsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MCPUserEnvVarsStatus" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Store Mcp User Env Vars", + "tags": [ + "mcp_management" + ] + } + }, "/v1/mcp/tools": { "get": { "description": "Get all MCP tools available for the current key, including those from access groups", @@ -17746,6 +18856,37 @@ "mcp_management" ] } + }, + "/v1/mcp/user-env-vars/status": { + "get": { + "description": "Per-user MCP env var status across every server the user can access. Used by the dashboard to highlight servers with missing per-user vars.", + "operationId": "list_mcp_user_env_var_status_v1_mcp_user_env_vars_status_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/MCPUserEnvVarsStatus" + }, + "title": "Response List Mcp User Env Var Status V1 Mcp User Env Vars Status Get", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "List Mcp User Env Var Status", + "tags": [ + "mcp_management" + ] + } } } }, diff --git a/litellm/proxy/management_endpoints/mcp_connector_import.py b/litellm/proxy/management_endpoints/mcp_connector_import.py new file mode 100644 index 00000000000..d576131ae1b --- /dev/null +++ b/litellm/proxy/management_endpoints/mcp_connector_import.py @@ -0,0 +1,168 @@ +""" +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, 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=entry.args, + env=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 + credentials: Final = _bearer_credentials(entry.authorization_token) + 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=MCPAuth.bearer_token if entry.authorization_token else MCPAuth.none, + credentials=credentials, + static_headers=entry.headers, + ) + except ValidationError as e: + return ConnectorConversionError(name=name, error=_first_validation_message(e)) + return ConvertedConnector(name=name, request=remote_request) + + +def _bearer_credentials(token: str | None) -> MCPCredentials | None: + if not token: + return None + credentials: Final[MCPCredentials] = {"auth_value": token} + return credentials + + +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.", + ) diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index 54a591a5e1a..6583d5405f8 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -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, @@ -1607,6 +1618,112 @@ 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)) + 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: + # Best-effort registry refresh, mirroring add_mcp_server: rows are + # already committed, so a refresh failure must not surface as a 500. + 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", diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_connector_import.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_connector_import.py new file mode 100644 index 00000000000..9f2874bcbf3 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_connector_import.py @@ -0,0 +1,157 @@ +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_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) diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py index 0d639e1cb6a..7b3580f166a 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -6766,3 +6766,143 @@ 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() + + 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.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() + + 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() + + @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() + + 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.reload_servers_from_database.assert_not_awaited() diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ImportMCPServers.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ImportMCPServers.tsx new file mode 100644 index 00000000000..d04160bca3e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ImportMCPServers.tsx @@ -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 = ({ accessToken, open, onClose, onImported }) => { + const [configText, setConfigText] = useState(""); + const [parseError, setParseError] = useState(null); + const [isImporting, setIsImporting] = useState(false); + const [result, setResult] = useState(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 ( + !isOpen && handleClose()}> + + + Import MCP Connectors + +
+

+ Paste an Anthropic connector configuration: the mcpServers mapping from a Claude Desktop / + Claude Code config file, or the mcp_servers array from the Anthropic Messages API. +

+