mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-02 16:21:54 +00:00
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:
co-authored by
greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
parent
918376ddaf
commit
9202e67e33
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
>
|
||||
←
|
||||
</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 }}>
|
||||
›
|
||||
</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
|
||||
|
||||
Reference in New Issue
Block a user