mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
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:
parent
5c8bd6a6ca
commit
0695aa1894
7 changed files with 706 additions and 91 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue