feat: MCP server discovery UI (#21079)

* feat: add curated MCP server registry for discovery UI

Curated list of 31 well-known MCP servers with names, icons,
categories, transport config, and registry URLs. Includes HTTP
endpoints for GitHub, Atlassian, Sentry, Snowflake, and Cloudflare.

* feat: add GET /v1/mcp/discover endpoint for MCP discovery

Admin-only endpoint that serves the curated MCP registry with
optional query and category filters. Used by the UI discovery modal.

* feat: add DiscoverableMCPServer types for MCP discovery

* feat: add fetchDiscoverableMCPServers network function

* feat: add MCP discovery modal component

Compact list-row layout with category filters, search, and
grouped server list. Follows dev-tool aesthetic.

* feat: wire MCP discovery modal into server management page

Add MCP Server button now opens discovery modal. Card click
pre-fills the create form. Custom Server opens blank form.

* feat: add prefill from discovery and back-to-registry link

Create form accepts prefillData from discovery selection and
shows a Browse MCP Registry link to return to discovery modal.

* test: add unit tests for MCP discovery endpoint and registry

Tests for registry JSON structure validation and endpoint
query/category filtering logic. 15 tests total.

* fix: sync registry with official MCP API and fix stdio prefill

- Updated transport types and URLs from registry.modelcontextprotocol.io API
- GitHub: streamable-http at api.githubcopilot.com/mcp/
- GitLab: streamable-http at gitlab.com/api/v4/mcp (remote only)
- Atlassian: SSE at mcp.atlassian.com/v1/sse (remote only)
- Linear: SSE at mcp.linear.app/sse (remote only)
- Notion: SSE at mcp.notion.com/sse (remote only)
- Stripe: streamable-http at mcp.stripe.com (remote only)
- Exa: streamable-http at mcp.exa.ai/mcp (remote only)
- Cloudflare: SSE at bindings.mcp.cloudflare.com/sse (remote only)
- Sentry: stdio via @sentry/mcp-server (npm, correct package)
- Snowflake: stdio via snowflake-labs-mcp (pypi/uvx, not npm)
- Brave Search: stdio via @brave/brave-search-mcp-server (correct package)
- Fixed stdio prefill to generate stdio_config JSON instead of separate fields
- Discovery modal matches create modal width and header style
- Back arrow positioned on left of create modal header

* Update ui/litellm-dashboard/src/components/mcp_tools/mcp_discovery.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update litellm/proxy/management_endpoints/mcp_management_endpoints.py

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix: address Greptile review feedback

- Move `import json` and `import os` to module top level
- Move mcp_registry.json into litellm/proxy/ for pip distribution
- Fix `Text` component: destructure from antd Typography instead of deprecated Tremor
- Update test fixture path to match new registry location

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Ishaan Jaff
2026-02-12 17:59:21 -08:00
committed by GitHub
co-authored by greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
parent 918376ddaf
commit 9202e67e33
8 changed files with 1149 additions and 5 deletions
@@ -10,10 +10,13 @@ Endpoints here:
- DELETE `/v1/mcp/server/{server_id}` - Deletes the mcp server given `server_id`.
- GET `/v1/mcp/tools - lists all the tools available for a key
- GET `/v1/mcp/access_groups` - lists all available MCP access groups
- GET `/v1/mcp/discover` - Returns curated list of well-known MCP servers for discovery UI
"""
import importlib
import json
import os
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Dict, Iterable, List, Literal, Optional
@@ -1176,3 +1179,88 @@ if MCP_AVAILABLE:
except Exception as e:
verbose_proxy_logger.exception(f"Error making agent public: {e}")
raise HTTPException(status_code=500, detail=str(e))
# --- MCP Discovery ---
_MCP_REGISTRY_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"mcp_registry.json",
)
_mcp_registry_cache: Optional[Dict[str, Any]] = None
def _load_mcp_registry() -> Dict[str, Any]:
"""Load the curated MCP registry from disk. Cached after first read."""
global _mcp_registry_cache
if _mcp_registry_cache is not None:
return _mcp_registry_cache
try:
with open(_MCP_REGISTRY_PATH, "r") as f:
data: Dict[str, Any] = json.load(f)
except Exception as e:
verbose_proxy_logger.warning(
f"Failed to load MCP registry from {_MCP_REGISTRY_PATH}: {e}"
)
data = {"servers": []}
_mcp_registry_cache = data
return data
@router.get(
"/discover",
description="Returns a curated list of well-known MCP servers for discovery UI",
dependencies=[Depends(user_api_key_auth)],
)
async def discover_mcp_servers(
query: Optional[str] = Query(
None, description="Search filter for server names and descriptions"
),
category: Optional[str] = Query(
None, description="Filter by category"
),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Returns a curated list of well-known MCP servers that can be added to the proxy.
Used by the UI to show a discovery grid when adding new MCP servers.
"""
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
raise HTTPException(
status_code=403,
detail={
"error": "Only proxy admins can access MCP discovery. Your role={}".format(
user_api_key_dict.user_role
)
},
)
registry = _load_mcp_registry()
servers = registry.get("servers", [])
# Apply query filter
if query:
query_lower = query.lower()
servers = [
s
for s in servers
if query_lower in s.get("name", "").lower()
or query_lower in s.get("title", "").lower()
or query_lower in s.get("description", "").lower()
]
# Apply category filter
if category:
servers = [
s for s in servers if s.get("category", "") == category
]
# Extract unique categories from the full list (before filtering)
all_servers = registry.get("servers", [])
categories = sorted(
set(s.get("category", "Other") for s in all_servers)
)
return {
"servers": servers,
"categories": categories,
}
+426
View File
@@ -0,0 +1,426 @@
{
"servers": [
{
"name": "github",
"title": "GitHub",
"description": "Manage repos, issues, PRs, and workflows through natural language",
"icon_url": "https://cdn.simpleicons.org/github",
"category": "Developer Tools",
"registry_url": "https://registry.modelcontextprotocol.io/servers/io.github.github%2Fgithub-mcp-server",
"transport": "http",
"url": "https://api.githubcopilot.com/mcp/",
"env_vars": [
{"name": "GITHUB_PERSONAL_ACCESS_TOKEN", "description": "GitHub Personal Access Token", "secret": true}
]
},
{
"name": "gitlab",
"title": "GitLab",
"description": "Official GitLab MCP Server for project and repository management",
"icon_url": "https://cdn.simpleicons.org/gitlab",
"category": "Developer Tools",
"registry_url": "https://registry.modelcontextprotocol.io/servers/com.gitlab%2Fmcp",
"transport": "http",
"url": "https://gitlab.com/api/v4/mcp",
"env_vars": [
{"name": "GITLAB_PERSONAL_ACCESS_TOKEN", "description": "GitLab Personal Access Token", "secret": true}
]
},
{
"name": "atlassian",
"title": "Atlassian (Jira & Confluence)",
"description": "Jira issues, Confluence pages, and Atlassian product integration",
"icon_url": "https://cdn.simpleicons.org/atlassian",
"category": "Developer Tools",
"registry_url": "https://registry.modelcontextprotocol.io/servers/com.atlassian%2Fatlassian-mcp-server",
"transport": "sse",
"url": "https://mcp.atlassian.com/v1/sse",
"env_vars": []
},
{
"name": "linear",
"title": "Linear",
"description": "Issue tracking, project management, and team workflow automation",
"icon_url": "https://cdn.simpleicons.org/linear",
"category": "Developer Tools",
"registry_url": "https://registry.modelcontextprotocol.io/servers/app.linear%2Flinear",
"transport": "sse",
"url": "https://mcp.linear.app/sse",
"env_vars": []
},
{
"name": "sentry",
"title": "Sentry",
"description": "Error monitoring, issue tracking, and debugging for AI assistants",
"icon_url": "https://cdn.simpleicons.org/sentry",
"category": "Developer Tools",
"registry_url": "https://registry.modelcontextprotocol.io/servers/io.github.getsentry%2Fsentry-mcp",
"transport": "stdio",
"command": "npx",
"args": ["-y", "@sentry/mcp-server"],
"env_vars": [
{"name": "SENTRY_ACCESS_TOKEN", "description": "Sentry Access Token", "secret": true}
]
},
{
"name": "slack",
"title": "Slack",
"description": "Channel management, messaging, and Slack workspace integration",
"icon_url": "https://cdn.simpleicons.org/slack",
"category": "Communication",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-slack"],
"env_vars": [
{"name": "SLACK_BOT_TOKEN", "description": "Slack Bot User OAuth Token", "secret": true},
{"name": "SLACK_TEAM_ID", "description": "Slack Team/Workspace ID", "secret": false}
]
},
{
"name": "discord",
"title": "Discord",
"description": "Discord server management, messaging, and bot integration",
"icon_url": "https://cdn.simpleicons.org/discord",
"category": "Communication",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-discord"],
"env_vars": [
{"name": "DISCORD_BOT_TOKEN", "description": "Discord Bot Token", "secret": true}
]
},
{
"name": "postgresql",
"title": "PostgreSQL",
"description": "Query and manage PostgreSQL databases with read-only access",
"icon_url": "https://cdn.simpleicons.org/postgresql",
"category": "Databases",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres"],
"env_vars": [
{"name": "POSTGRES_CONNECTION_STRING", "description": "PostgreSQL connection string (e.g., postgresql://user:pass@host:5432/db)", "secret": true}
]
},
{
"name": "sqlite",
"title": "SQLite",
"description": "Query and manage SQLite databases",
"icon_url": "https://cdn.simpleicons.org/sqlite",
"category": "Databases",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-sqlite"],
"env_vars": [
{"name": "SQLITE_DB_PATH", "description": "Path to SQLite database file", "secret": false}
]
},
{
"name": "mysql",
"title": "MySQL",
"description": "Query and manage MySQL databases",
"icon_url": "https://cdn.simpleicons.org/mysql",
"category": "Databases",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-mysql"],
"env_vars": [
{"name": "MYSQL_HOST", "description": "MySQL host", "secret": false},
{"name": "MYSQL_USER", "description": "MySQL username", "secret": false},
{"name": "MYSQL_PASSWORD", "description": "MySQL password", "secret": true},
{"name": "MYSQL_DATABASE", "description": "MySQL database name", "secret": false}
]
},
{
"name": "mongodb",
"title": "MongoDB",
"description": "Query and manage MongoDB databases and collections",
"icon_url": "https://cdn.simpleicons.org/mongodb",
"category": "Databases",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-mongodb"],
"env_vars": [
{"name": "MONGODB_CONNECTION_STRING", "description": "MongoDB connection string", "secret": true}
]
},
{
"name": "redis",
"title": "Redis",
"description": "Interact with Redis key-value stores",
"icon_url": "https://cdn.simpleicons.org/redis",
"category": "Databases",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-redis"],
"env_vars": [
{"name": "REDIS_URL", "description": "Redis connection URL (e.g., redis://localhost:6379)", "secret": true}
]
},
{
"name": "snowflake",
"title": "Snowflake",
"description": "MCP Server for Snowflake from Snowflake Labs",
"icon_url": "https://cdn.simpleicons.org/snowflake",
"category": "Databases",
"registry_url": "https://registry.modelcontextprotocol.io/servers/io.github.Snowflake-Labs%2Fmcp",
"transport": "stdio",
"command": "uvx",
"args": ["snowflake-labs-mcp"],
"env_vars": [
{"name": "SNOWFLAKE_ACCOUNT", "description": "Snowflake account identifier (e.g., xy12345.us-east-1)", "secret": false},
{"name": "SNOWFLAKE_USER", "description": "Snowflake username", "secret": false},
{"name": "SNOWFLAKE_PASSWORD", "description": "Snowflake password", "secret": true}
]
},
{
"name": "notion",
"title": "Notion",
"description": "Official Notion MCP server for pages and databases",
"icon_url": "https://cdn.simpleicons.org/notion",
"category": "Productivity",
"registry_url": "https://registry.modelcontextprotocol.io/servers/com.notion%2Fmcp",
"transport": "sse",
"url": "https://mcp.notion.com/sse",
"env_vars": []
},
{
"name": "google_drive",
"title": "Google Drive",
"description": "Search and access files in Google Drive",
"icon_url": "https://cdn.simpleicons.org/googledrive",
"category": "Productivity",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-gdrive"],
"env_vars": [
{"name": "GOOGLE_CLIENT_ID", "description": "Google OAuth Client ID", "secret": false},
{"name": "GOOGLE_CLIENT_SECRET", "description": "Google OAuth Client Secret", "secret": true}
]
},
{
"name": "google_calendar",
"title": "Google Calendar",
"description": "Manage events and calendars in Google Calendar",
"icon_url": "https://cdn.simpleicons.org/googlecalendar",
"category": "Productivity",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-google-calendar"],
"env_vars": [
{"name": "GOOGLE_CLIENT_ID", "description": "Google OAuth Client ID", "secret": false},
{"name": "GOOGLE_CLIENT_SECRET", "description": "Google OAuth Client Secret", "secret": true}
]
},
{
"name": "obsidian",
"title": "Obsidian",
"description": "Read, search, and manage Obsidian vault notes and files",
"icon_url": "https://cdn.simpleicons.org/obsidian",
"category": "Productivity",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-obsidian"],
"env_vars": [
{"name": "OBSIDIAN_VAULT_PATH", "description": "Path to Obsidian vault directory", "secret": false}
]
},
{
"name": "brave_search",
"title": "Brave Search",
"description": "Web results, images, videos, and AI summaries via Brave Search API",
"icon_url": "https://cdn.simpleicons.org/brave",
"category": "Search",
"registry_url": "https://registry.modelcontextprotocol.io/servers/io.github.brave%2Fbrave-search-mcp-server",
"transport": "stdio",
"command": "npx",
"args": ["-y", "@brave/brave-search-mcp-server"],
"env_vars": [
{"name": "BRAVE_API_KEY", "description": "Brave Search API Key", "secret": true}
]
},
{
"name": "exa",
"title": "Exa",
"description": "Fast, intelligent web search and web crawling",
"icon_url": "https://cdn.simpleicons.org/exa",
"category": "Search",
"registry_url": "https://registry.modelcontextprotocol.io/servers/ai.exa%2Fexa",
"transport": "http",
"url": "https://mcp.exa.ai/mcp",
"env_vars": [
{"name": "EXA_API_KEY", "description": "Exa API Key", "secret": true}
]
},
{
"name": "tavily",
"title": "Tavily",
"description": "AI-optimized search engine for research and retrieval",
"icon_url": "https://cdn.simpleicons.org/tavily",
"category": "Search",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-tavily"],
"env_vars": [
{"name": "TAVILY_API_KEY", "description": "Tavily API Key", "secret": true}
]
},
{
"name": "puppeteer",
"title": "Puppeteer",
"description": "Browser automation, web scraping, and screenshot capture",
"icon_url": "https://cdn.simpleicons.org/puppeteer",
"category": "Web & Browser",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-puppeteer"],
"env_vars": []
},
{
"name": "playwright",
"title": "Playwright",
"description": "Browser automation and testing with Playwright",
"icon_url": "https://cdn.simpleicons.org/playwright",
"category": "Web & Browser",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-playwright"],
"env_vars": []
},
{
"name": "browserbase",
"title": "Browserbase",
"description": "Cloud browser automation and session management",
"icon_url": "https://cdn.simpleicons.org/browserbase",
"category": "Web & Browser",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-browserbase"],
"env_vars": [
{"name": "BROWSERBASE_API_KEY", "description": "Browserbase API Key", "secret": true},
{"name": "BROWSERBASE_PROJECT_ID", "description": "Browserbase Project ID", "secret": false}
]
},
{
"name": "aws",
"title": "AWS",
"description": "Interact with Amazon Web Services resources and APIs",
"icon_url": "https://cdn.simpleicons.org/amazonaws",
"category": "Cloud",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-aws"],
"env_vars": [
{"name": "AWS_ACCESS_KEY_ID", "description": "AWS Access Key ID", "secret": true},
{"name": "AWS_SECRET_ACCESS_KEY", "description": "AWS Secret Access Key", "secret": true},
{"name": "AWS_REGION", "description": "AWS Region (e.g., us-east-1)", "secret": false}
]
},
{
"name": "cloudflare",
"title": "Cloudflare",
"description": "Manage Cloudflare Workers, KV, R2, D1, and more",
"icon_url": "https://cdn.simpleicons.org/cloudflare",
"category": "Cloud",
"registry_url": "https://registry.modelcontextprotocol.io/servers/com.cloudflare.mcp%2Fmcp",
"transport": "sse",
"url": "https://bindings.mcp.cloudflare.com/sse",
"env_vars": []
},
{
"name": "filesystem",
"title": "Filesystem",
"description": "Read, write, and manage files and directories on disk",
"icon_url": "https://cdn.simpleicons.org/files",
"category": "System",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
"env_vars": []
},
{
"name": "docker",
"title": "Docker",
"description": "Manage Docker containers, images, and networks",
"icon_url": "https://cdn.simpleicons.org/docker",
"category": "System",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-docker"],
"env_vars": []
},
{
"name": "stripe",
"title": "Stripe",
"description": "Manage payments, customers, and subscriptions via Stripe",
"icon_url": "https://cdn.simpleicons.org/stripe",
"category": "Finance",
"registry_url": "https://registry.modelcontextprotocol.io/servers/com.stripe%2Fmcp",
"transport": "http",
"url": "https://mcp.stripe.com",
"env_vars": []
},
{
"name": "shopify",
"title": "Shopify",
"description": "Manage Shopify stores, products, orders, and customers",
"icon_url": "https://cdn.simpleicons.org/shopify",
"category": "E-Commerce",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-shopify"],
"env_vars": [
{"name": "SHOPIFY_ACCESS_TOKEN", "description": "Shopify Admin API Access Token", "secret": true},
{"name": "SHOPIFY_STORE_URL", "description": "Shopify Store URL (e.g., mystore.myshopify.com)", "secret": false}
]
},
{
"name": "twilio",
"title": "Twilio",
"description": "Send SMS, make calls, and manage communication via Twilio",
"icon_url": "https://cdn.simpleicons.org/twilio",
"category": "Communication",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-twilio"],
"env_vars": [
{"name": "TWILIO_ACCOUNT_SID", "description": "Twilio Account SID", "secret": false},
{"name": "TWILIO_AUTH_TOKEN", "description": "Twilio Auth Token", "secret": true}
]
},
{
"name": "supabase",
"title": "Supabase",
"description": "Manage Supabase projects, databases, and storage",
"icon_url": "https://cdn.simpleicons.org/supabase",
"category": "Databases",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-supabase"],
"env_vars": [
{"name": "SUPABASE_URL", "description": "Supabase Project URL", "secret": false},
{"name": "SUPABASE_SERVICE_ROLE_KEY", "description": "Supabase Service Role Key", "secret": true}
]
}
]
}
@@ -0,0 +1,182 @@
import json
import os
import sys
import pytest
sys.path.insert(
0, os.path.abspath("../../../..")
) # Adds the parent directory to the system path
class TestMCPRegistryFile:
"""Tests for the curated MCP registry JSON file."""
@pytest.fixture
def registry_path(self):
return os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..",
"..",
"..",
"..",
"..",
"litellm",
"proxy",
"mcp_registry.json",
)
def test_registry_file_exists(self, registry_path):
assert os.path.exists(registry_path), f"Registry file not found at {registry_path}"
def test_registry_file_is_valid_json(self, registry_path):
with open(registry_path, "r") as f:
data = json.load(f)
assert isinstance(data, dict)
assert "servers" in data
def test_registry_servers_have_required_fields(self, registry_path):
with open(registry_path, "r") as f:
data = json.load(f)
servers = data["servers"]
assert len(servers) > 0, "Registry should have at least one server"
required_fields = ["name", "title", "description", "category", "transport"]
for server in servers:
for field in required_fields:
assert field in server, f"Server {server.get('name', '?')} missing field '{field}'"
def test_registry_server_names_are_unique(self, registry_path):
with open(registry_path, "r") as f:
data = json.load(f)
names = [s["name"] for s in data["servers"]]
assert len(names) == len(set(names)), f"Duplicate server names found: {[n for n in names if names.count(n) > 1]}"
def test_registry_transport_values_are_valid(self, registry_path):
with open(registry_path, "r") as f:
data = json.load(f)
valid_transports = {"stdio", "http", "sse"}
for server in data["servers"]:
assert server["transport"] in valid_transports, (
f"Server {server['name']} has invalid transport '{server['transport']}'"
)
def test_stdio_servers_have_command(self, registry_path):
with open(registry_path, "r") as f:
data = json.load(f)
for server in data["servers"]:
if server["transport"] == "stdio":
assert "command" in server and server["command"], (
f"stdio server {server['name']} missing 'command'"
)
def test_http_servers_have_url(self, registry_path):
with open(registry_path, "r") as f:
data = json.load(f)
for server in data["servers"]:
if server["transport"] in ("http", "sse"):
assert "url" in server and server["url"], (
f"HTTP/SSE server {server['name']} missing 'url'"
)
def test_well_known_servers_present(self, registry_path):
"""Ensure key well-known MCPs are in the registry."""
with open(registry_path, "r") as f:
data = json.load(f)
names = {s["name"] for s in data["servers"]}
expected = {"github", "slack", "postgresql", "snowflake", "atlassian"}
missing = expected - names
assert not missing, f"Missing well-known servers: {missing}"
def test_env_vars_structure(self, registry_path):
with open(registry_path, "r") as f:
data = json.load(f)
for server in data["servers"]:
if "env_vars" in server:
assert isinstance(server["env_vars"], list)
for var in server["env_vars"]:
assert "name" in var, f"env_var in {server['name']} missing 'name'"
class TestDiscoverEndpointFiltering:
"""Tests for the discover endpoint filtering logic (unit-level)."""
@pytest.fixture
def sample_servers(self):
return [
{
"name": "github",
"title": "GitHub",
"description": "Repository management",
"category": "Developer Tools",
"transport": "http",
"url": "https://mcp.github.com/sse",
},
{
"name": "slack",
"title": "Slack",
"description": "Channel management and messaging",
"category": "Communication",
"transport": "stdio",
"command": "npx",
},
{
"name": "postgresql",
"title": "PostgreSQL",
"description": "Query and manage databases",
"category": "Databases",
"transport": "stdio",
"command": "npx",
},
]
def test_query_filter_by_name(self, sample_servers):
query = "github"
q = query.lower()
result = [
s
for s in sample_servers
if q in s.get("name", "").lower()
or q in s.get("title", "").lower()
or q in s.get("description", "").lower()
]
assert len(result) == 1
assert result[0]["name"] == "github"
def test_query_filter_by_description(self, sample_servers):
query = "messaging"
q = query.lower()
result = [
s
for s in sample_servers
if q in s.get("name", "").lower()
or q in s.get("title", "").lower()
or q in s.get("description", "").lower()
]
assert len(result) == 1
assert result[0]["name"] == "slack"
def test_category_filter(self, sample_servers):
category = "Databases"
result = [s for s in sample_servers if s.get("category") == category]
assert len(result) == 1
assert result[0]["name"] == "postgresql"
def test_no_filter_returns_all(self, sample_servers):
assert len(sample_servers) == 3
def test_query_filter_no_match(self, sample_servers):
query = "nonexistent"
q = query.lower()
result = [
s
for s in sample_servers
if q in s.get("name", "").lower()
or q in s.get("title", "").lower()
or q in s.get("description", "").lower()
]
assert len(result) == 0
def test_categories_extraction(self, sample_servers):
categories = sorted(set(s.get("category", "Other") for s in sample_servers))
assert categories == ["Communication", "Databases", "Developer Tools"]
@@ -3,7 +3,7 @@ import { Modal, Tooltip, Form, Select, Input } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, TextInput } from "@tremor/react";
import { createMCPServer } from "../networking";
import { AUTH_TYPE, OAUTH_FLOW, MCPServer, MCPServerCostInfo } from "./types";
import { AUTH_TYPE, DiscoverableMCPServer, OAUTH_FLOW, MCPServer, MCPServerCostInfo } from "./types";
import OAuthFormFields from "./OAuthFormFields";
import MCPServerCostConfig from "./mcp_server_cost_config";
import MCPConnectionStatus from "./mcp_connection_status";
@@ -25,6 +25,8 @@ interface CreateMCPServerProps {
isModalVisible: boolean;
setModalVisible: (visible: boolean) => void;
availableAccessGroups: string[];
prefillData?: DiscoverableMCPServer | null;
onBackToDiscovery?: () => void;
}
const AUTH_TYPES_REQUIRING_AUTH_VALUE = [AUTH_TYPE.API_KEY, AUTH_TYPE.BEARER_TOKEN, AUTH_TYPE.BASIC];
@@ -38,6 +40,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
isModalVisible,
setModalVisible,
availableAccessGroups,
prefillData,
onBackToDiscovery,
}) => {
const [form] = Form.useForm();
const [isLoading, setIsLoading] = useState(false);
@@ -183,6 +187,50 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
setPendingRestoredValues(null);
}, [pendingRestoredValues, form, transportType]);
// Pre-fill form from discovery selection
React.useEffect(() => {
if (!isModalVisible || !prefillData) {
return;
}
// Sanitize server name: strip vendor prefix, replace hyphens with underscores
const sanitizedName = (prefillData.name || "")
.replace(/[^a-zA-Z0-9_]/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "");
const transport = prefillData.transport || "";
setTransportType(transport);
const prefillValues: Record<string, any> = {
server_name: sanitizedName,
alias: sanitizedName,
description: prefillData.description || "",
transport: transport,
};
if (transport === "stdio") {
const stdioObj: Record<string, any> = {};
if (prefillData.command) stdioObj.command = prefillData.command;
if (prefillData.args && prefillData.args.length > 0) stdioObj.args = prefillData.args;
if (prefillData.env_vars && prefillData.env_vars.length > 0) {
const envObj: Record<string, string> = {};
for (const v of prefillData.env_vars) {
envObj[v.name] = v.description ? `<${v.description}>` : "";
}
stdioObj.env = envObj;
}
if (Object.keys(stdioObj).length > 0) {
prefillValues.stdio_config = JSON.stringify(stdioObj, null, 2);
}
} else if (prefillData.url) {
prefillValues.url = prefillData.url;
}
form.setFieldsValue(prefillValues);
setFormValues(prefillValues);
setAliasManuallyEdited(false);
}, [isModalVisible, prefillData, form]);
const handleCreate = async (values: Record<string, any>) => {
setIsLoading(true);
try {
@@ -391,7 +439,16 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
return (
<Modal
title={
<div className="flex items-center space-x-3 pb-4 border-b border-gray-100">
<div className="flex items-center pb-4 border-b border-gray-100" style={{ gap: 12 }}>
{onBackToDiscovery && (
<button
onClick={onBackToDiscovery}
className="text-sm text-blue-600 hover:text-blue-800 cursor-pointer bg-transparent border-none"
style={{ flexShrink: 0 }}
>
&#8592;
</button>
)}
<img
src={mcpLogoImg}
alt="MCP Logo"
@@ -399,7 +456,6 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
style={{
height: "20px",
width: "20px",
marginRight: "8px",
objectFit: "contain",
}}
/>
@@ -0,0 +1,320 @@
import React, { useState, useMemo, useEffect } from "react";
import { Modal, Input, Typography } from "antd";
import { fetchDiscoverableMCPServers } from "../networking";
import { DiscoverableMCPServer, DiscoverMCPServersResponse } from "./types";
import { mcpLogoImg } from "./create_mcp_server";
const { Search } = Input;
const { Text } = Typography;
interface MCPDiscoveryProps {
isVisible: boolean;
onClose: () => void;
onSelectServer: (server: DiscoverableMCPServer) => void;
onCustomServer: () => void;
accessToken: string | null;
}
const INITIAL_COLORS = [
"#3B82F6",
"#10B981",
"#F59E0B",
"#EF4444",
"#8B5CF6",
"#EC4899",
"#06B6D4",
"#84CC16",
];
function getInitialAvatar(name: string) {
const initial = name.charAt(0).toUpperCase();
const colorIndex =
name.split("").reduce((acc, ch) => acc + ch.charCodeAt(0), 0) %
INITIAL_COLORS.length;
return { initial, backgroundColor: INITIAL_COLORS[colorIndex] };
}
const MCPDiscovery: React.FC<MCPDiscoveryProps> = ({
isVisible,
onClose,
onSelectServer,
onCustomServer,
accessToken,
}) => {
const [servers, setServers] = useState<DiscoverableMCPServer[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState("All");
useEffect(() => {
if (isVisible && accessToken) {
setLoading(true);
setError(null);
fetchDiscoverableMCPServers(accessToken)
.then((data: DiscoverMCPServersResponse) => {
setServers(data.servers || []);
setCategories(data.categories || []);
})
.catch((err: Error) => {
setError(err.message || "Failed to load MCP servers");
})
.finally(() => {
setLoading(false);
});
}
}, [isVisible, accessToken]);
useEffect(() => {
if (isVisible) {
setSearchQuery("");
setSelectedCategory("All");
}
}, [isVisible]);
const filteredServers = useMemo(() => {
let result = servers;
if (selectedCategory !== "All") {
result = result.filter((s) => s.category === selectedCategory);
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(
(s) =>
s.name.toLowerCase().includes(q) ||
s.title.toLowerCase().includes(q) ||
s.description.toLowerCase().includes(q),
);
}
return result;
}, [servers, selectedCategory, searchQuery]);
const groupedServers = useMemo(() => {
const groups: Record<string, DiscoverableMCPServer[]> = {};
for (const server of filteredServers) {
const cat = server.category || "Other";
if (!groups[cat]) groups[cat] = [];
groups[cat].push(server);
}
return groups;
}, [filteredServers]);
return (
<Modal
title={
<div className="flex items-center justify-between pb-4 border-b border-gray-100">
<div className="flex items-center space-x-3">
<img
src={mcpLogoImg}
alt="MCP Logo"
className="w-8 h-8 object-contain"
style={{
height: "20px",
width: "20px",
marginRight: "8px",
objectFit: "contain",
}}
/>
<h2 className="text-xl font-semibold text-gray-900">Add MCP Server</h2>
</div>
<button
onClick={onCustomServer}
className="text-sm text-blue-600 hover:text-blue-800 cursor-pointer bg-transparent border-none font-medium"
>
+ Custom Server
</button>
</div>
}
open={isVisible}
onCancel={onClose}
footer={null}
width={1000}
className="top-8"
styles={{
body: { padding: "24px", maxHeight: "70vh", overflowY: "auto" },
header: { padding: "24px 24px 0 24px", border: "none" },
}}
>
{/* Filter pills */}
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 12 }}>
{["All", ...categories].map((cat) => {
const isSelected = selectedCategory === cat;
return (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
style={{
padding: "4px 12px",
borderRadius: 4,
border: isSelected ? "1px solid #111827" : "1px solid #e5e7eb",
background: isSelected ? "#111827" : "#fff",
color: isSelected ? "#fff" : "#4b5563",
cursor: "pointer",
fontSize: 12,
fontWeight: isSelected ? 500 : 400,
lineHeight: "20px",
}}
>
{cat}
</button>
);
})}
</div>
{/* Search */}
<Search
placeholder="Search servers..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{ marginBottom: 16 }}
allowClear
/>
{/* Loading skeleton */}
{loading && (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
style={{
height: 36,
borderRadius: 6,
background: "#f9fafb",
}}
/>
))}
</div>
)}
{error && (
<div style={{ textAlign: "center", padding: "32px 0", color: "#9ca3af" }}>
<Text>Failed to load servers: {error}</Text>
</div>
)}
{!loading && !error && filteredServers.length === 0 && (
<div style={{ textAlign: "center", padding: "32px 0", color: "#9ca3af" }}>
<Text>
No servers found.{" "}
<a
onClick={onCustomServer}
style={{ color: "#2563eb", cursor: "pointer" }}
>
Add a custom server
</a>
</Text>
</div>
)}
{/* Server list grouped by category — 2 columns */}
{!loading &&
!error &&
Object.entries(groupedServers).map(([category, categoryServers]) => (
<div key={category} style={{ marginBottom: 16 }}>
<div
style={{
fontSize: 11,
fontWeight: 500,
color: "#9ca3af",
textTransform: "uppercase",
letterSpacing: "0.05em",
padding: "6px 0",
borderBottom: "1px solid #f3f4f6",
marginBottom: 4,
}}
>
{category}
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0 16px",
}}
>
{categoryServers.map((server) => {
const avatar = getInitialAvatar(server.title || server.name);
return (
<div
key={server.name}
onClick={() => onSelectServer(server)}
style={{
display: "flex",
alignItems: "center",
padding: "8px 10px",
borderRadius: 6,
cursor: "pointer",
transition: "background 0.1s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "#f9fafb";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
}}
>
{server.icon_url ? (
<img
src={server.icon_url}
alt={server.title}
style={{
width: 20,
height: 20,
objectFit: "contain",
flexShrink: 0,
marginRight: 12,
}}
onError={(e) => {
const target = e.currentTarget;
target.style.display = "none";
const next = target.nextElementSibling as HTMLElement;
if (next) next.style.display = "flex";
}}
/>
) : null}
<div
style={{
width: 20,
height: 20,
borderRadius: 4,
backgroundColor: avatar.backgroundColor,
color: "#fff",
display: server.icon_url ? "none" : "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 600,
fontSize: 11,
flexShrink: 0,
marginRight: 12,
}}
>
{avatar.initial}
</div>
<span
style={{
fontSize: 14,
fontWeight: 400,
color: "#111827",
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{server.title || server.name}
</span>
<span style={{ color: "#d1d5db", fontSize: 14, flexShrink: 0, marginLeft: 8 }}>
&#8250;
</span>
</div>
);
})}
</div>
</div>
))}
</Modal>
);
};
export default MCPDiscovery;
@@ -12,9 +12,10 @@ import CreateMCPServer from "./create_mcp_server";
import MCPConnect from "./mcp_connect";
import { mcpServerColumns } from "./mcp_server_columns";
import { MCPServerView } from "./mcp_server_view";
import { MCPServer, MCPServerProps, Team } from "./types";
import { DiscoverableMCPServer, MCPServer, MCPServerProps, Team } from "./types";
import MCPSemanticFilterSettings from "../Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings";
import MCPNetworkSettings from "./MCPNetworkSettings";
import MCPDiscovery from "./mcp_discovery";
const { Text: AntdText, Title: AntdTitle } = Typography;
const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state";
@@ -66,6 +67,8 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
const [selectedMcpAccessGroup, setSelectedMcpAccessGroup] = useState<string>("all");
const [filteredServers, setFilteredServers] = useState<MCPServer[]>([]);
const [isModalVisible, setModalVisible] = useState(false);
const [isDiscoveryVisible, setDiscoveryVisible] = useState(false);
const [prefillData, setPrefillData] = useState<DiscoverableMCPServer | null>(null);
const [isDeletingServer, setIsDeletingServer] = useState(false);
const isInternalUser = userRole === "Internal User";
@@ -291,14 +294,35 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
isModalVisible={isModalVisible}
setModalVisible={setModalVisible}
availableAccessGroups={uniqueMcpAccessGroups}
prefillData={prefillData}
onBackToDiscovery={() => {
setModalVisible(false);
setPrefillData(null);
setDiscoveryVisible(true);
}}
/>
<Title>MCP Servers</Title>
<Text className="text-tremor-content mt-2">Configure and manage your MCP servers</Text>
{isAdminRole(userRole) && (
<Button className="mt-4 mb-4" onClick={() => setModalVisible(true)}>
<Button className="mt-4 mb-4" onClick={() => setDiscoveryVisible(true)}>
+ Add New MCP Server
</Button>
)}
<MCPDiscovery
isVisible={isDiscoveryVisible}
onClose={() => setDiscoveryVisible(false)}
onSelectServer={(server: DiscoverableMCPServer) => {
setPrefillData(server);
setDiscoveryVisible(false);
setModalVisible(true);
}}
onCustomServer={() => {
setPrefillData(null);
setDiscoveryVisible(false);
setModalVisible(true);
}}
accessToken={accessToken}
/>
<TabGroup className="w-full h-full">
<TabList className="flex justify-between mt-2 w-full items-center">
<div className="flex">
@@ -175,3 +175,23 @@ export interface MCPServerProps {
userRole: string | null;
userID: string | null;
}
// Discoverable MCP server from the curated registry
export interface DiscoverableMCPServer {
name: string;
title: string;
description: string;
icon_url?: string | null;
category: string;
registry_url?: string | null;
transport: string;
url?: string | null;
command?: string | null;
args?: string[] | null;
env_vars?: Array<{ name: string; description?: string; secret?: boolean }> | null;
}
export interface DiscoverMCPServersResponse {
servers: DiscoverableMCPServer[];
categories: string[];
}
@@ -6097,6 +6097,34 @@ export const updateInternalUserSettings = async (accessToken: string, settings:
}
};
export const fetchDiscoverableMCPServers = async (accessToken: string) => {
try {
const url = proxyBaseUrl
? `${proxyBaseUrl}/v1/mcp/discover`
: `/v1/mcp/discover`;
const response = await fetch(url, {
method: HTTP_REQUEST.GET,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
return await response.json();
} catch (error) {
console.error("Failed to fetch discoverable MCP servers:", error);
throw error;
}
};
export const fetchMCPServers = async (accessToken: string) => {
try {
// Construct base URL