feat(mcp): popular REST API gallery for OpenAPI MCPs + OAuth2 user connect in ChatUI

Admin: when adding an OpenAPI MCP, shows a gallery of 12 well-known APIs (GitHub,
Figma, Jira, Confluence, Slack, Stripe, Notion, Linear, HubSpot, Salesforce,
Zendesk, Snowflake) with logos. Clicking one pre-fills the OpenAPI spec URL and
OAuth2 URLs/scopes so the admin doesn't need to look them up.

User (ChatUI Apps panel): OAuth2 MCP servers now show a "Sign In" button instead
of a toggle. After OAuth flow completes the panel shows a "Connected" badge.

Backend:
- Add 12 REST API entries to mcp_registry.json (category "REST APIs")
- Add GET /v1/mcp/user/credential/{server_id} endpoint to check per-user creds
- Extend has_user_credential annotation to cover oauth2 servers, not just BYOK
- Fix _get_tools_from_server to check spec_path before creating MCP client,
  fixing GitHub/Figma tool loading (client creation attempted m2m auth which
  fails for auth-code-only providers)
- Propagate is_byok/byok_description/byok_api_key_help_url from YAML config
  into MCPServer when loading from proxy_config.yaml
This commit is contained in:
Ishaan Jaffer 2026-03-06 18:28:48 -08:00
parent 5c8bd6a6ca
commit 0695aa1894
7 changed files with 706 additions and 91 deletions

View file

@ -315,6 +315,10 @@ class MCPServerManager:
command=server_config.get("command", None) or "",
args=server_config.get("args", None) or [],
env=server_config.get("env", None) or {},
# byok fields
is_byok=bool(server_config.get("is_byok", False)),
byok_description=server_config.get("byok_description", []) or [],
byok_api_key_help_url=server_config.get("byok_api_key_help_url", None),
# oauth specific fields
client_id=server_config.get("client_id", None),
client_secret=server_config.get("client_secret", None),
@ -1008,22 +1012,21 @@ class MCPServerManager:
extra_headers = {}
extra_headers.update(server.static_headers)
stdio_env = self._build_stdio_env(server, raw_headers)
client = await self._create_mcp_client(
server=server,
mcp_auth_header=mcp_auth_header,
extra_headers=extra_headers,
stdio_env=stdio_env,
)
## HANDLE OPENAPI TOOLS
## HANDLE OPENAPI TOOLS — skip MCP client creation entirely
if server.spec_path:
_tools = global_mcp_tool_registry.list_tools(tool_prefix=server.name)
tools = global_mcp_tool_registry.convert_tools_to_mcp_sdk_tool_type(
_tools
)
else:
stdio_env = self._build_stdio_env(server, raw_headers)
client = await self._create_mcp_client(
server=server,
mcp_auth_header=mcp_auth_header,
extra_headers=extra_headers,
stdio_env=stdio_env,
)
tools = await self._fetch_tools_with_timeout(client, server.name)
prefixed_or_original_tools = self._create_prefixed_tools(

View file

@ -82,6 +82,7 @@ if MCP_AVAILABLE:
get_all_mcp_servers_for_user,
get_mcp_server,
get_user_credential,
has_user_credential,
store_user_credential,
update_mcp_server,
)
@ -604,23 +605,24 @@ if MCP_AVAILABLE:
server.mcp_info = {}
server.mcp_info["is_public"] = True
# Annotate has_user_credential for BYOK servers (single batched query)
# Annotate has_user_credential for BYOK and OAuth2 user-token servers (single batched query)
from litellm.proxy.proxy_server import prisma_client as _byok_prisma_client
user_id = user_api_key_dict.user_id or ""
if user_id and _byok_prisma_client is not None:
byok_server_ids = [
user_cred_server_ids = [
s.server_id
for s in redacted_mcp_servers
if getattr(s, "is_byok", False)
or getattr(s, "auth_type", None) == "oauth2"
]
if byok_server_ids:
if user_cred_server_ids:
cred_rows = await _byok_prisma_client.db.litellm_mcpusercredentials.find_many(
where={"user_id": user_id, "server_id": {"in": byok_server_ids}}
where={"user_id": user_id, "server_id": {"in": user_cred_server_ids}}
)
cred_set = {r.server_id for r in cred_rows}
for server in redacted_mcp_servers:
if getattr(server, "is_byok", False):
if getattr(server, "is_byok", False) or getattr(server, "auth_type", None) == "oauth2":
server.has_user_credential = server.server_id in cred_set
# Virtual keys only get a sanitized discovery view.
@ -1060,6 +1062,30 @@ if MCP_AVAILABLE:
return Response(status_code=status.HTTP_202_ACCEPTED)
@router.get(
"/user/credential/{server_id}",
description="Check whether the calling user has a stored credential for an MCP server",
dependencies=[Depends(user_api_key_auth)],
response_model=MCPUserCredentialResponse,
)
@management_endpoint_wrapper
async def get_mcp_user_credential(
server_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""Check if the calling user has a credential stored for this MCP server."""
prisma_client = get_prisma_client_or_throw(
"Database not connected. Connect a database to use BYOK MCP servers"
)
user_id = user_api_key_dict.user_id
if not user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": "User ID not found in token"},
)
has_cred = await has_user_credential(prisma_client, user_id, server_id)
return MCPUserCredentialResponse(server_id=server_id, has_credential=has_cred)
@router.post(
"/server/{server_id}/user-credential",
description="Store or update the calling user's API key for a BYOK MCP server",

View file

@ -421,6 +421,162 @@
{"name": "SUPABASE_URL", "description": "Supabase Project URL", "secret": false},
{"name": "SUPABASE_SERVICE_ROLE_KEY", "description": "Supabase Service Role Key", "secret": true}
]
},
{
"name": "github_openapi",
"title": "GitHub",
"description": "Repos, issues, PRs, and code search — imported from GitHub's OpenAPI spec",
"icon_url": "https://cdn.simpleicons.org/github",
"category": "REST APIs",
"transport": "openapi",
"openapi_spec_url": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions-next/api.github.com/api.github.com.json",
"auth_type": "oauth2",
"authorization_url": "https://github.com/login/oauth/authorize",
"token_url": "https://github.com/login/oauth/access_token",
"default_scopes": ["repo", "user"]
},
{
"name": "figma_openapi",
"title": "Figma",
"description": "Read and write Figma files, comments, and components via Figma REST API",
"icon_url": "https://cdn.simpleicons.org/figma",
"category": "REST APIs",
"transport": "openapi",
"openapi_spec_url": "https://raw.githubusercontent.com/figma/rest-api-spec/main/openapi/openapi.yaml",
"auth_type": "oauth2",
"authorization_url": "https://www.figma.com/oauth",
"token_url": "https://api.figma.com/v1/oauth/token",
"default_scopes": ["file_read"]
},
{
"name": "jira_openapi",
"title": "Jira",
"description": "Issues, sprints, projects, and boards via Atlassian Jira REST API",
"icon_url": "https://cdn.simpleicons.org/jira",
"category": "REST APIs",
"transport": "openapi",
"openapi_spec_url": "https://developer.atlassian.com/cloud/jira/platform/swagger-v3.v3.json",
"auth_type": "oauth2",
"authorization_url": "https://auth.atlassian.com/authorize",
"token_url": "https://auth.atlassian.com/oauth/token",
"default_scopes": ["read:jira-work", "write:jira-work", "offline_access"]
},
{
"name": "confluence_openapi",
"title": "Confluence",
"description": "Pages, spaces, and search via Atlassian Confluence REST API",
"icon_url": "https://cdn.simpleicons.org/confluence",
"category": "REST APIs",
"transport": "openapi",
"openapi_spec_url": "https://developer.atlassian.com/cloud/confluence/swagger.v3.json",
"auth_type": "oauth2",
"authorization_url": "https://auth.atlassian.com/authorize",
"token_url": "https://auth.atlassian.com/oauth/token",
"default_scopes": ["read:confluence-content.all", "write:confluence-content", "offline_access"]
},
{
"name": "snowflake_openapi",
"title": "Snowflake",
"description": "Query and manage Snowflake data warehouses via Snowflake REST API",
"icon_url": "https://cdn.simpleicons.org/snowflake",
"category": "REST APIs",
"transport": "openapi",
"openapi_spec_url": "https://docs.snowflake.com/en/_downloads/openapi.json",
"auth_type": "oauth2",
"authorization_url": "https://<account>.snowflakecomputing.com/oauth/authorize",
"token_url": "https://<account>.snowflakecomputing.com/oauth/token-request",
"default_scopes": ["session:role:PUBLIC"]
},
{
"name": "slack_openapi",
"title": "Slack",
"description": "Messages, channels, users, and files via Slack Web API",
"icon_url": "https://cdn.simpleicons.org/slack",
"category": "REST APIs",
"transport": "openapi",
"openapi_spec_url": "https://raw.githubusercontent.com/slackapi/slack-api-specs/master/web-api/slack_web_openapi_v2.json",
"auth_type": "oauth2",
"authorization_url": "https://slack.com/oauth/v2/authorize",
"token_url": "https://slack.com/api/oauth.v2.access",
"default_scopes": ["channels:read", "chat:write", "files:read", "users:read"]
},
{
"name": "hubspot_openapi",
"title": "HubSpot",
"description": "Contacts, deals, companies, and marketing workflows via HubSpot CRM API",
"icon_url": "https://cdn.simpleicons.org/hubspot",
"category": "REST APIs",
"transport": "openapi",
"openapi_spec_url": "https://api.hubspot.com/api-catalog-public/v1/apis",
"auth_type": "oauth2",
"authorization_url": "https://app.hubspot.com/oauth/authorize",
"token_url": "https://api.hubspot.com/oauth/v1/token",
"default_scopes": ["crm.objects.contacts.read", "crm.objects.deals.read", "crm.objects.companies.read"]
},
{
"name": "salesforce_openapi",
"title": "Salesforce",
"description": "Leads, opportunities, accounts, and custom objects via Salesforce REST API",
"icon_url": "https://cdn.simpleicons.org/salesforce",
"category": "REST APIs",
"transport": "openapi",
"openapi_spec_url": "https://developer.salesforce.com/docs/platform/rest/guide/intro_rest_resources.htm",
"auth_type": "oauth2",
"authorization_url": "https://login.salesforce.com/services/oauth2/authorize",
"token_url": "https://login.salesforce.com/services/oauth2/token",
"default_scopes": ["api", "refresh_token"]
},
{
"name": "zendesk_openapi",
"title": "Zendesk",
"description": "Tickets, users, and organizations via Zendesk Support REST API",
"icon_url": "https://cdn.simpleicons.org/zendesk",
"category": "REST APIs",
"transport": "openapi",
"openapi_spec_url": "https://developer.zendesk.com/api-reference/ticketing/introduction/",
"auth_type": "oauth2",
"authorization_url": "https://{subdomain}.zendesk.com/oauth/authorizations/new",
"token_url": "https://{subdomain}.zendesk.com/oauth/tokens",
"default_scopes": ["read", "write"]
},
{
"name": "notion_openapi",
"title": "Notion",
"description": "Pages, databases, and blocks via Notion REST API",
"icon_url": "https://cdn.simpleicons.org/notion",
"category": "REST APIs",
"transport": "openapi",
"openapi_spec_url": "https://raw.githubusercontent.com/notion-community/notion-openapi/main/notion-api.yaml",
"auth_type": "oauth2",
"authorization_url": "https://api.notion.com/v1/oauth/authorize",
"token_url": "https://api.notion.com/v1/oauth/token",
"default_scopes": []
},
{
"name": "linear_openapi",
"title": "Linear",
"description": "Issues, projects, cycles, and teams via Linear REST API",
"icon_url": "https://cdn.simpleicons.org/linear",
"category": "REST APIs",
"transport": "openapi",
"openapi_spec_url": "https://raw.githubusercontent.com/linear/linear/master/packages/sdk/src/_generated_documents.ts",
"auth_type": "oauth2",
"authorization_url": "https://linear.app/oauth/authorize",
"token_url": "https://api.linear.app/oauth/token",
"default_scopes": ["read", "write"]
},
{
"name": "stripe_openapi",
"title": "Stripe",
"description": "Payments, subscriptions, customers, and invoices via Stripe REST API",
"icon_url": "https://cdn.simpleicons.org/stripe",
"category": "REST APIs",
"transport": "openapi",
"openapi_spec_url": "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json",
"auth_type": "bearer_token",
"authorization_url": null,
"token_url": null,
"default_scopes": []
}
]
}

View file

@ -1,9 +1,9 @@
"use client";
import React, { useEffect, useState } from "react";
import { Switch, Spin, Input, Button } from "antd";
import { SearchOutlined, ArrowLeftOutlined, RightOutlined } from "@ant-design/icons";
import { fetchMCPServers, listMCPTools } from "../networking";
import React, { useEffect, useState, useCallback } from "react";
import { Spin, Input, Button, Tag } from "antd";
import { SearchOutlined, ArrowLeftOutlined, RightOutlined, CheckCircleFilled, LinkOutlined } from "@ant-design/icons";
import { fetchMCPServers, listMCPTools, checkMCPUserCredential, deleteMCPUserCredential, proxyBaseUrl } from "../networking";
import { MCPServer } from "../mcp_tools/types";
import { message } from "antd";
@ -24,6 +24,37 @@ function getAvatarColor(name: string): string {
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
}
function ServerAvatar({ server, size = 38 }: { server: MCPServer; size?: number }) {
const name = server.server_name ?? server.alias ?? server.server_id;
const logoUrl = server.mcp_info?.logo_url;
const color = getAvatarColor(name);
const radius = size === 64 ? 16 : 10;
const fontSize = size === 64 ? 28 : 16;
const [imgError, setImgError] = useState(false);
if (logoUrl && !imgError) {
return (
<img
src={logoUrl}
alt={name}
onError={() => setImgError(true)}
style={{ width: size, height: size, borderRadius: radius, objectFit: "contain", flexShrink: 0, background: "#f9fafb", border: "1px solid #e5e7eb" }}
/>
);
}
return (
<div style={{
width: size, height: size, borderRadius: radius, background: color,
display: "flex", alignItems: "center", justifyContent: "center",
color: "#fff", fontWeight: 700, fontSize, flexShrink: 0,
}}>
{name.charAt(0).toUpperCase()}
</div>
);
}
type TabKey = "all" | "connected";
const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange }) => {
@ -33,8 +64,12 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
const [activeTab, setActiveTab] = useState<TabKey>("all");
const [togglingOn, setTogglingOn] = useState<Set<string>>(new Set());
const [detailServer, setDetailServer] = useState<MCPServer | null>(null);
// credential state for the detail view (OAuth2 / BYOK servers)
const [hasCredential, setHasCredential] = useState<boolean | null>(null);
const [credLoading, setCredLoading] = useState(false);
const [disconnecting, setDisconnecting] = useState(false);
useEffect(() => {
const loadServers = useCallback(() => {
let cancelled = false;
setLoading(true);
fetchMCPServers(accessToken)
@ -43,15 +78,52 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
const list: MCPServer[] = Array.isArray(data) ? data : (data?.data ?? []);
setServers(list);
})
.catch(() => {
if (!cancelled) setServers([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
.catch(() => { if (!cancelled) setServers([]); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [accessToken]);
useEffect(() => {
const cancel = loadServers();
return cancel;
}, [loadServers]);
// Handle return from OAuth — URL param ?mcp_oauth_complete=<server_id>
useEffect(() => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
const completedServerId = params.get("mcp_oauth_complete");
if (!completedServerId) return;
// Remove the param from URL without reload
const newUrl = new URL(window.location.href);
newUrl.searchParams.delete("mcp_oauth_complete");
window.history.replaceState({}, "", newUrl.toString());
// Reload servers and open the detail for the completed server
loadServers();
}, [loadServers]);
const isOAuth2Server = (s: MCPServer) => (s as MCPServer & { auth_type?: string }).auth_type === "oauth2";
// When the detail server changes, check credential status for OAuth2 / BYOK servers
useEffect(() => {
if (!detailServer) { setHasCredential(null); return; }
const needsCheck = isOAuth2Server(detailServer) || (detailServer as MCPServer & { is_byok?: boolean }).is_byok;
if (!needsCheck) { setHasCredential(null); return; }
// Try from the server list first (has_user_credential is annotated server-side)
const annotated = (detailServer as MCPServer & { has_user_credential?: boolean }).has_user_credential;
if (annotated !== undefined) {
setHasCredential(annotated);
return;
}
// Fall back to individual API call
setCredLoading(true);
checkMCPUserCredential(accessToken, detailServer.server_id)
.then((res) => setHasCredential(res.has_credential))
.catch(() => setHasCredential(false))
.finally(() => setCredLoading(false));
}, [detailServer, accessToken]);
const handleToggle = async (serverName: string, checked: boolean) => {
if (!checked) {
onChange(selectedServers.filter((s) => s !== serverName));
@ -69,13 +141,38 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
message.warning(`Could not load tools for ${serverName}`);
} finally {
setTogglingOn((prev) => {
const next = new Set(prev);
next.delete(serverName);
return next;
const next = new Set(prev); next.delete(serverName); return next;
});
}
};
const handleOAuthSignIn = (server: MCPServer) => {
const base = proxyBaseUrl ?? "";
const redirectUri = window.location.href.split("?")[0] + `?mcp_oauth_complete=${server.server_id}`;
const authorizeUrl = `${base}/v1/mcp/oauth/authorize?server_id=${encodeURIComponent(server.server_id)}&redirect_uri=${encodeURIComponent(redirectUri)}`;
window.location.href = authorizeUrl;
};
const handleDisconnect = async (server: MCPServer) => {
const name = server.server_name ?? server.alias ?? server.server_id;
setDisconnecting(true);
try {
await deleteMCPUserCredential(accessToken, server.server_id);
setHasCredential(false);
onChange(selectedServers.filter((s) => s !== name));
// Update the server in the list
setServers((prev) => prev.map((s) =>
s.server_id === server.server_id
? { ...s, has_user_credential: false } as MCPServer
: s
));
} catch {
message.error("Failed to disconnect");
} finally {
setDisconnecting(false);
}
};
const nameOf = (s: MCPServer) => s.server_name ?? s.alias ?? s.server_id;
const filtered = servers.filter((s) => {
@ -83,18 +180,26 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
const matchesQuery = !query.trim() ||
name.toLowerCase().includes(query.toLowerCase()) ||
(s.description ?? "").toLowerCase().includes(query.toLowerCase());
const matchesTab = activeTab === "all" || selectedServers.includes(name);
const matchesTab = activeTab === "all" || selectedServers.includes(name) ||
(isOAuth2Server(s) && (s as MCPServer & { has_user_credential?: boolean }).has_user_credential);
return matchesQuery && matchesTab;
});
const connectedCount = servers.filter((s) => selectedServers.includes(nameOf(s))).length;
const connectedCount = servers.filter((s) => {
const name = nameOf(s);
if (selectedServers.includes(name)) return true;
if (isOAuth2Server(s) && (s as MCPServer & { has_user_credential?: boolean }).has_user_credential) return true;
return false;
}).length;
// ── Detail view ──
if (detailServer) {
const name = nameOf(detailServer);
const isConnected = selectedServers.includes(name);
const isTogglingOn = togglingOn.has(name);
const color = getAvatarColor(name);
const isOAuth2 = isOAuth2Server(detailServer);
const isByok = (detailServer as MCPServer & { is_byok?: boolean }).is_byok;
const needsUserAuth = isOAuth2 || isByok;
return (
<div style={{ width: "100%" }}>
@ -113,41 +218,67 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
{/* Avatar + name + connect */}
<div style={{ display: "flex", alignItems: "flex-start", gap: 20, marginBottom: 28 }}>
<div style={{
width: 64, height: 64, borderRadius: 16,
background: color, display: "flex",
alignItems: "center", justifyContent: "center",
color: "#fff", fontWeight: 700, fontSize: 28, flexShrink: 0,
}}>
{name.charAt(0).toUpperCase()}
</div>
<ServerAvatar server={detailServer} size={64} />
<div style={{ flex: 1 }}>
<h2 style={{ margin: "0 0 4px", fontSize: 22, fontWeight: 700, color: "#111827" }}>{name}</h2>
<p style={{ margin: 0, fontSize: 14, color: "#6b7280" }}>{detailServer.description ?? "MCP server"}</p>
</div>
<Button
type={isConnected ? "default" : "primary"}
loading={isTogglingOn}
onClick={() => handleToggle(name, !isConnected)}
style={{ borderRadius: 8, fontWeight: 600, height: 38, minWidth: 110 }}
>
{isConnected ? "Disconnect" : "Connect"}
</Button>
{/* Connect button — OAuth2 path */}
{needsUserAuth && (
credLoading ? (
<Spin size="small" />
) : hasCredential ? (
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 6 }}>
<Tag icon={<CheckCircleFilled />} color="success" style={{ margin: 0, fontSize: 12, padding: "4px 10px" }}>
Connected
</Tag>
<Button
size="small"
danger
loading={disconnecting}
onClick={() => handleDisconnect(detailServer)}
style={{ borderRadius: 6, fontSize: 12 }}
>
Disconnect
</Button>
</div>
) : (
<Button
type="primary"
icon={<LinkOutlined />}
onClick={() => handleOAuthSignIn(detailServer)}
style={{ borderRadius: 8, fontWeight: 600, height: 38, minWidth: 110 }}
>
Sign In
</Button>
)
)}
{/* Connect button — standard (non-OAuth2) path */}
{!needsUserAuth && (
<Button
type={isConnected ? "default" : "primary"}
loading={isTogglingOn}
onClick={() => handleToggle(name, !isConnected)}
style={{ borderRadius: 8, fontWeight: 600, height: 38, minWidth: 110 }}
>
{isConnected ? "Disconnect" : "Connect"}
</Button>
)}
</div>
{/* Info table */}
<h3 style={{ margin: "0 0 12px", fontSize: 15, fontWeight: 600, color: "#111827" }}>Information</h3>
<div style={{ border: "1px solid #e5e7eb", borderRadius: 10, overflow: "hidden" }}>
{[
{([
["Server ID", detailServer.server_id],
["Transport", (detailServer as MCPServer & { mcp_info?: { server_url?: string } }).mcp_info?.server_url ? "HTTP" : "stdio"],
["Status", isConnected ? "Connected" : "Not connected"],
].filter(([, v]) => v).map(([label, value], i, arr) => (
["Auth", isOAuth2 ? "OAuth 2.0 (sign in with your account)" : isByok ? "API Key (BYOK)" : "None"],
["Status", (needsUserAuth ? hasCredential : isConnected) ? "Connected" : "Not connected"],
] as [string, string][]).filter(([, v]) => v).map(([label, value], i, arr) => (
<div key={label} style={{
display: "flex",
padding: "12px 16px",
borderBottom: i < arr.length - 1 ? "1px solid #f3f4f6" : "none",
fontSize: 13,
display: "flex", padding: "12px 16px",
borderBottom: i < arr.length - 1 ? "1px solid #f3f4f6" : "none", fontSize: 13,
}}>
<span style={{ width: 140, color: "#9ca3af", flexShrink: 0 }}>{label}</span>
<span style={{ color: "#111827", fontWeight: 500 }}>{value}</span>
@ -166,7 +297,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20, gap: 16, flexWrap: "wrap" }}>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 2 }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: "#111827" }}>MCP Servers</h2>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: "#111827" }}>Apps</h2>
<span style={{
fontSize: 10, fontWeight: 600, color: "#1677ff",
background: "#e8f4ff", borderRadius: 4, padding: "1px 6px",
@ -174,12 +305,12 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
}}>Beta</span>
</div>
<p style={{ margin: 0, fontSize: 13, color: "#6b7280" }}>
Connect tools to your chat.
Connect apps to your chat.
</p>
</div>
<Input
prefix={<SearchOutlined style={{ color: "#9ca3af", fontSize: 13 }} />}
placeholder="Search servers..."
placeholder="Search apps..."
value={query}
onChange={(e) => setQuery(e.target.value)}
allowClear
@ -217,15 +348,17 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
) : filtered.length === 0 ? (
<div style={{ textAlign: "center", color: "#9ca3af", fontSize: 13, padding: "48px 12px" }}>
{servers.length === 0
? "No MCP servers configured. Add servers in Tools → MCP Servers."
: activeTab === "connected" ? "No servers connected yet." : "No servers match your search."}
? "No apps configured. Add servers in Tools → MCP Servers."
: activeTab === "connected" ? "No apps connected yet." : "No apps match your search."}
</div>
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 0, border: "1px solid #e5e7eb", borderRadius: 10, overflow: "hidden" }}>
{filtered.map((server, idx) => {
const name = nameOf(server);
const isConnected = selectedServers.includes(name);
const color = getAvatarColor(name);
const isOAuth2 = isOAuth2Server(server);
const hasCred = (server as MCPServer & { has_user_credential?: boolean }).has_user_credential;
const isLinked = isConnected || (isOAuth2 && hasCred);
const isLeftCol = idx % 2 === 0;
return (
@ -237,19 +370,12 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
padding: "14px 16px", background: "#fff",
borderRight: isLeftCol ? "1px solid #f3f4f6" : "none",
borderBottom: Math.floor(idx / 2) < Math.floor((filtered.length - 1) / 2) ? "1px solid #f3f4f6" : "none",
cursor: "pointer", minWidth: 0,
transition: "background 0.1s",
cursor: "pointer", minWidth: 0, transition: "background 0.1s",
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLDivElement).style.background = "#fafafa"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.background = "#fff"; }}
>
<div style={{
width: 38, height: 38, borderRadius: 10, background: color,
display: "flex", alignItems: "center", justifyContent: "center",
color: "#fff", fontWeight: 700, fontSize: 16, flexShrink: 0,
}}>
{name.charAt(0).toUpperCase()}
</div>
<ServerAvatar server={server} size={38} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 500, color: "#111827", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{name}
@ -258,8 +384,11 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
{server.description ?? "MCP server"}
</div>
</div>
{isConnected && (
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#1677ff", flexShrink: 0 }} />
{isLinked && (
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#52c41a", flexShrink: 0 }} />
)}
{isOAuth2 && !hasCred && (
<span style={{ fontSize: 11, color: "#9ca3af", flexShrink: 0, marginRight: 2 }}>Sign In</span>
)}
<RightOutlined style={{ fontSize: 11, color: "#d1d5db", flexShrink: 0 }} />
</div>

View file

@ -4,6 +4,162 @@ import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, TextInput } from "@tremor/react";
import { createMCPServer } from "../networking";
import { AUTH_TYPE, DiscoverableMCPServer, OAUTH_FLOW, MCPServer, MCPServerCostInfo, TRANSPORT } from "./types";
const WELL_KNOWN_OPENAPI_SERVERS: DiscoverableMCPServer[] = [
{
name: "github_openapi",
title: "GitHub",
description: "Repos, issues, PRs, and code search",
icon_url: "https://cdn.simpleicons.org/github/black",
category: "REST APIs",
transport: TRANSPORT.OPENAPI,
openapi_spec_url: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions-next/api.github.com/api.github.com.json",
auth_type: "oauth2",
authorization_url: "https://github.com/login/oauth/authorize",
token_url: "https://github.com/login/oauth/access_token",
default_scopes: ["repo", "user"],
},
{
name: "figma_openapi",
title: "Figma",
description: "Files, comments, and components",
icon_url: "https://cdn.simpleicons.org/figma",
category: "REST APIs",
transport: TRANSPORT.OPENAPI,
openapi_spec_url: "https://raw.githubusercontent.com/figma/rest-api-spec/main/openapi/openapi.yaml",
auth_type: "oauth2",
authorization_url: "https://www.figma.com/oauth",
token_url: "https://api.figma.com/v1/oauth/token",
default_scopes: ["file_read"],
},
{
name: "jira_openapi",
title: "Jira",
description: "Issues, sprints, and projects",
icon_url: "https://cdn.simpleicons.org/jira",
category: "REST APIs",
transport: TRANSPORT.OPENAPI,
openapi_spec_url: "https://developer.atlassian.com/cloud/jira/platform/swagger-v3.v3.json",
auth_type: "oauth2",
authorization_url: "https://auth.atlassian.com/authorize",
token_url: "https://auth.atlassian.com/oauth/token",
default_scopes: ["read:jira-work", "write:jira-work"],
},
{
name: "confluence_openapi",
title: "Confluence",
description: "Pages, spaces, and search",
icon_url: "https://cdn.simpleicons.org/confluence",
category: "REST APIs",
transport: TRANSPORT.OPENAPI,
openapi_spec_url: "https://developer.atlassian.com/cloud/confluence/swagger.v3.json",
auth_type: "oauth2",
authorization_url: "https://auth.atlassian.com/authorize",
token_url: "https://auth.atlassian.com/oauth/token",
default_scopes: ["read:confluence-content.all"],
},
{
name: "slack_openapi",
title: "Slack",
description: "Messages, channels, and files",
icon_url: "https://cdn.simpleicons.org/slack",
category: "REST APIs",
transport: TRANSPORT.OPENAPI,
openapi_spec_url: "https://raw.githubusercontent.com/slackapi/slack-api-specs/master/web-api/slack_web_openapi_v2.json",
auth_type: "oauth2",
authorization_url: "https://slack.com/oauth/v2/authorize",
token_url: "https://slack.com/api/oauth.v2.access",
default_scopes: ["channels:read", "chat:write"],
},
{
name: "stripe_openapi",
title: "Stripe",
description: "Payments, subscriptions, and invoices",
icon_url: "https://cdn.simpleicons.org/stripe",
category: "REST APIs",
transport: TRANSPORT.OPENAPI,
openapi_spec_url: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json",
auth_type: "bearer_token",
},
{
name: "notion_openapi",
title: "Notion",
description: "Pages, databases, and search",
icon_url: "https://cdn.simpleicons.org/notion/black",
category: "REST APIs",
transport: TRANSPORT.OPENAPI,
openapi_spec_url: "https://developers.notion.com",
auth_type: "oauth2",
authorization_url: "https://api.notion.com/v1/oauth/authorize",
token_url: "https://api.notion.com/v1/oauth/token",
default_scopes: [],
},
{
name: "linear_openapi",
title: "Linear",
description: "Issues, projects, and teams",
icon_url: "https://cdn.simpleicons.org/linear",
category: "REST APIs",
transport: TRANSPORT.OPENAPI,
openapi_spec_url: "https://api.linear.app/graphql",
auth_type: "oauth2",
authorization_url: "https://linear.app/oauth/authorize",
token_url: "https://api.linear.app/oauth/token",
default_scopes: ["read", "write"],
},
{
name: "hubspot_openapi",
title: "HubSpot",
description: "Contacts, deals, and marketing",
icon_url: "https://cdn.simpleicons.org/hubspot",
category: "REST APIs",
transport: TRANSPORT.OPENAPI,
openapi_spec_url: "https://api.hubspot.com/api-catalog-public/v1/apis",
auth_type: "oauth2",
authorization_url: "https://app.hubspot.com/oauth/authorize",
token_url: "https://api.hubspot.com/oauth/v1/token",
default_scopes: ["crm.objects.contacts.read"],
},
{
name: "salesforce_openapi",
title: "Salesforce",
description: "Leads, opportunities, and accounts",
icon_url: "https://cdn.simpleicons.org/salesforce",
category: "REST APIs",
transport: TRANSPORT.OPENAPI,
openapi_spec_url: "https://developer.salesforce.com/docs/platform/rest/guide/intro_rest_resources.htm",
auth_type: "oauth2",
authorization_url: "https://login.salesforce.com/services/oauth2/authorize",
token_url: "https://login.salesforce.com/services/oauth2/token",
default_scopes: ["api"],
},
{
name: "zendesk_openapi",
title: "Zendesk",
description: "Tickets, users, and support workflows",
icon_url: "https://cdn.simpleicons.org/zendesk",
category: "REST APIs",
transport: TRANSPORT.OPENAPI,
openapi_spec_url: "https://developer.zendesk.com/api-reference/ticketing/introduction/",
auth_type: "oauth2",
authorization_url: "https://{subdomain}.zendesk.com/oauth/authorizations/new",
token_url: "https://{subdomain}.zendesk.com/oauth/tokens",
default_scopes: ["read", "write"],
},
{
name: "snowflake_openapi",
title: "Snowflake",
description: "Queries and data warehouses",
icon_url: "https://cdn.simpleicons.org/snowflake",
category: "REST APIs",
transport: TRANSPORT.OPENAPI,
openapi_spec_url: "https://docs.snowflake.com/en/_downloads/openapi.json",
auth_type: "oauth2",
authorization_url: "https://<account>.snowflakecomputing.com/oauth/authorize",
token_url: "https://<account>.snowflakecomputing.com/oauth/token-request",
default_scopes: ["session:role:SYSADMIN"],
},
];
import OAuthFormFields from "./OAuthFormFields";
import MCPServerCostConfig from "./mcp_server_cost_config";
import MCPConnectionStatus from "./mcp_connection_status";
@ -59,6 +215,9 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
const [transportType, setTransportType] = useState<string>("");
const [searchValue, setSearchValue] = useState<string>("");
const [oauthAccessToken, setOauthAccessToken] = useState<string | null>(null);
const [openApiServers] = useState<DiscoverableMCPServer[]>(WELL_KNOWN_OPENAPI_SERVERS);
const [selectedOpenApiServer, setSelectedOpenApiServer] = useState<DiscoverableMCPServer | null>(null);
const [showCustomUrl, setShowCustomUrl] = useState(false);
const authType = formValues.auth_type as string | undefined;
const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false;
const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2;
@ -242,6 +401,14 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
if (Object.keys(stdioObj).length > 0) {
prefillValues.stdio_config = JSON.stringify(stdioObj, null, 2);
}
} else if (transport === TRANSPORT.OPENAPI) {
if (prefillData.openapi_spec_url) prefillValues.spec_path = prefillData.openapi_spec_url;
if (prefillData.auth_type) prefillValues.auth_type = prefillData.auth_type;
if (prefillData.authorization_url) prefillValues.authorization_url = prefillData.authorization_url;
if (prefillData.token_url) prefillValues.token_url = prefillData.token_url;
if (prefillData.default_scopes && prefillData.default_scopes.length > 0) {
prefillValues.credentials = { scopes: prefillData.default_scopes };
}
} else if (prefillData.url) {
prefillValues.url = prefillData.url;
}
@ -403,6 +570,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
const handleTransportChange = (value: string) => {
setTransportType(value);
setSelectedOpenApiServer(null);
setShowCustomUrl(false);
// Clear fields that are not relevant for the selected transport
if (value === "stdio") {
form.setFieldsValue({ url: undefined, spec_path: undefined, auth_type: undefined, credentials: undefined });
@ -458,9 +627,35 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
React.useEffect(() => {
if (!isModalVisible) {
setFormValues({});
setSelectedOpenApiServer(null);
setShowCustomUrl(false);
}
}, [isModalVisible]);
const handleSelectOpenApiServer = (server: DiscoverableMCPServer) => {
setSelectedOpenApiServer(server);
setShowCustomUrl(false);
const sanitizedName = (server.name || "")
.replace(/[^a-zA-Z0-9_]/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "");
const updates: Record<string, any> = {
server_name: sanitizedName,
alias: sanitizedName,
description: server.description || "",
};
if (server.openapi_spec_url) updates.spec_path = server.openapi_spec_url;
if (server.auth_type) updates.auth_type = server.auth_type;
if (server.authorization_url) updates.authorization_url = server.authorization_url;
if (server.token_url) updates.token_url = server.token_url;
if (server.default_scopes && server.default_scopes.length > 0) {
updates.credentials = { scopes: server.default_scopes };
}
form.setFieldsValue(updates);
setFormValues((prev) => ({ ...prev, ...updates }));
setAliasManuallyEdited(false);
};
// rendering
if (!isAdminRole(userRole)) {
return null;
@ -603,25 +798,95 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
</Form.Item>
)}
{/* OpenAPI Spec URL - only show for OpenAPI transport */}
{/* OpenAPI: gallery of well-known APIs + custom URL */}
{transportType === TRANSPORT.OPENAPI && (
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
OpenAPI Spec URL
<Tooltip title="URL to an OpenAPI specification (JSON or YAML). MCP tools will be automatically generated from the API endpoints defined in the spec.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="spec_path"
rules={[{ required: true, message: "Please enter an OpenAPI spec URL" }]}
>
<Input
placeholder="https://petstore3.swagger.io/api/v3/openapi.json"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<>
{openApiServers.length > 0 && (
<div>
<div className="text-sm font-medium text-gray-700 mb-3">Popular APIs</div>
<div className="grid grid-cols-3 gap-3 mb-4">
{openApiServers.map((server) => {
const isSelected = selectedOpenApiServer?.name === server.name;
return (
<button
key={server.name}
type="button"
onClick={() => handleSelectOpenApiServer(server)}
className="text-left p-3 rounded-lg border transition-all"
style={{
border: isSelected ? "2px solid #2563eb" : "1px solid #e5e7eb",
background: isSelected ? "#eff6ff" : "#fff",
cursor: "pointer",
}}
>
<div className="flex items-center gap-2 mb-1">
{server.icon_url ? (
<img
src={server.icon_url}
alt={server.title}
className="w-5 h-5 object-contain flex-shrink-0"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<div
className="w-5 h-5 rounded flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
style={{ background: "#6366f1" }}
>
{server.title.charAt(0)}
</div>
)}
<span className="text-sm font-semibold text-gray-900 truncate">{server.title}</span>
</div>
<p className="text-xs text-gray-500 line-clamp-2 leading-snug">{server.description}</p>
{server.auth_type === "oauth2" && (
<span className="mt-1 inline-block text-xs text-blue-600 font-medium">OAuth 2.0</span>
)}
</button>
);
})}
</div>
{/* Divider */}
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 border-t border-gray-200" />
<span className="text-xs text-gray-400">or</span>
<div className="flex-1 border-t border-gray-200" />
</div>
</div>
)}
{/* Custom URL toggle */}
{!showCustomUrl && !selectedOpenApiServer && (
<button
type="button"
onClick={() => setShowCustomUrl(true)}
className="text-sm text-blue-600 hover:text-blue-800 font-medium mb-2 bg-transparent border-none cursor-pointer p-0"
>
+ Custom OpenAPI URL
</button>
)}
{/* Spec URL field: shown when custom or after selecting a server */}
{(showCustomUrl || selectedOpenApiServer) && (
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
OpenAPI Spec URL
<Tooltip title="URL to an OpenAPI specification (JSON or YAML). MCP tools will be automatically generated from the API endpoints defined in the spec.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="spec_path"
rules={[{ required: true, message: "Please enter an OpenAPI spec URL" }]}
>
<Input
placeholder="https://petstore3.swagger.io/api/v3/openapi.json"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
)}
</>
)}
{/* BYOK toggle - only for OpenAPI */}

View file

@ -205,6 +205,12 @@ export interface DiscoverableMCPServer {
command?: string | null;
args?: string[] | null;
env_vars?: Array<{ name: string; description?: string; secret?: boolean }> | null;
// OpenAPI-specific fields
openapi_spec_url?: string | null;
auth_type?: string | null;
authorization_url?: string | null;
token_url?: string | null;
default_scopes?: string[] | null;
}
export interface DiscoverMCPServersResponse {

View file

@ -6810,6 +6810,36 @@ export const callMCPTool = async (
}
};
export const checkMCPUserCredential = async (
accessToken: string,
serverId: string,
): Promise<{ has_credential: boolean }> => {
const url = proxyBaseUrl
? `${proxyBaseUrl}/v1/mcp/user/credential/${serverId}`
: `/v1/mcp/user/credential/${serverId}`;
const response = await fetch(url, {
method: "GET",
headers: { [globalLitellmHeaderName]: `Bearer ${accessToken}` },
});
if (!response.ok) {
return { has_credential: false };
}
return response.json();
};
export const deleteMCPUserCredential = async (
accessToken: string,
serverId: string,
): Promise<void> => {
const url = proxyBaseUrl
? `${proxyBaseUrl}/v1/mcp/server/${serverId}/user-credential`
: `/v1/mcp/server/${serverId}/user-credential`;
await fetch(url, {
method: "DELETE",
headers: { [globalLitellmHeaderName]: `Bearer ${accessToken}` },
});
};
export const tagCreateCall = async (accessToken: string, formValues: TagNewRequest): Promise<void> => {
try {
let url = proxyBaseUrl ? `${proxyBaseUrl}/tag/new` : `/tag/new`;