mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-08 20:22:16 +00:00
feat(ui): group MCP tools by CRUD risk category in allowlist panels (#23403)
* feat(ui): group MCP tools by CRUD risk category in tool permission panels Adds a CRUD-classification layer to the MCP tool allowlist UI so admins can allow/block an entire risk category (Read / Create / Update / Delete) with a single toggle instead of managing a flat list of individual tools. - New `mcpToolCrudClassification.ts` utility: regex-based classifier that buckets tool names/descriptions into read/create/update/delete/unknown - New `McpCrudPermissionPanel` component: collapsible sections per CRUD group, group-level Switch toggle, individual tool checkboxes, risk badges (green Safe / yellow Medium / red High Risk) - `mcp_tool_configuration.tsx`: adds "Risk Groups / Flat List" radio toggle; defaults to the CRUD-grouped view, flat list is still accessible - `MCPToolPermissions.tsx` (key/team assignment): replaces flat checkboxes with the CRUD panel; adds per-server view toggle; delete tools are blocked by default for newly-added servers (safer default for key/team scoping) No backend or schema changes — uses existing `allowed_tools` and `mcp_tool_permissions` fields. * fix(mcp): OAuth2 chat connect - tools fetch, auth flow, and status fixes - schema.prisma: add missing MCP table fields (approval_status, submitted_by, submitted_at, reviewed_at, review_notes) to prevent destructive migrations - rest_endpoints.py: inject user OAuth token via extra_headers for OAuth2 servers so tools list is populated; add server name->UUID resolution so MCPConnectPicker name lookups work - mcp_registry.json: fix Atlassian defaults (transport: http, url: .../v1/mcp) - ChatPage.tsx: read mcpOauthReturn param to init sidebarView="apps" on OAuth return, clean up param after mount - MCPAppsPanel.tsx: auto-add OAuth2 servers to selectedServers when credential detected; onConnect also enables server for chat; disconnect removes from selectedServers - mcp_servers.tsx: sort servers by created_at DESC - useUserMcpOAuthFlow.tsx: append mcpOauthReturn=apps to return URL so Apps panel is mounted on return * fix(mcp-crud-ui): address greptile review feedback - use Checkbox (not Switch) for group toggle so indeterminate works - add toolPermissionsRef to avoid stale closure race on concurrent server fetches - remove unused blockDeleteByDefault prop from McpCrudPermissionPanel - classify tools by name first; fall back to description only when name yields no match - add Risk Groups / Flat List toggle to mcp_tool_configuration.tsx * fix(mcp-crud-ui): address greptile 3/5 review - remove non-functional XIcon remove-server button (no onRemoveServer prop wired) - fix stale closure in MCPAppsPanel auto-enable effect: use serversRef/selectedServersRef - remove utility re-export from McpCrudPermissionPanel (classifyToolOp, groupToolsByCrud) - remove redundant selectedTools.length === 0 guard (always true when !toolPermissions[id]) * fix(mcp-crud-ui): address greptile 3/5 review round 2 - check READ_RE before DELETE_RE in classifyToolOp so tools like get_removed_entries are not silently blocked by delete-by-default - expand undefined (allow-all) to full tool name list instead of collapsing to [] (allow-none) in MCPToolPermissions and mcp_tool_configuration - log OAuth credential fetch failures instead of silently swallowing them * fix: cursor-pointer on read-only rows, stable sort, simplify handleCrudPanelChange * fix: sanitize user_id/server_id in log to prevent log injection * fix: add OAuth headers to call_tool_rest_api, fix stale accessToken closure, fix group toggle on filtered subset * fix: batch OAuth creds query, hide empty CRUD groups on search, onChange stability * fix: double-add race, conditional bulk query, narrow DELETE_RE, hoist search input * fix(mcp): clear oauthConnected on deselect; null guard on allowedTools prop * fix(mcp): remove user-provided values from debug log to fix log-injection lint * fix(mcp): fix allowedTools undefined semantics; remove unused import and color field
This commit is contained in:
@@ -8023,6 +8023,80 @@
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"black_forest_labs/flux-kontext-pro": {
|
||||
"litellm_provider": "black_forest_labs",
|
||||
"mode": "image_edit",
|
||||
"output_cost_per_image": 0.04,
|
||||
"source": "https://bfl.ai/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/images/edits",
|
||||
"/v1/images/generations"
|
||||
]
|
||||
},
|
||||
"black_forest_labs/flux-kontext-max": {
|
||||
"litellm_provider": "black_forest_labs",
|
||||
"mode": "image_edit",
|
||||
"output_cost_per_image": 0.08,
|
||||
"source": "https://bfl.ai/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/images/edits",
|
||||
"/v1/images/generations"
|
||||
]
|
||||
},
|
||||
"black_forest_labs/flux-pro-1.0-fill": {
|
||||
"litellm_provider": "black_forest_labs",
|
||||
"mode": "image_edit",
|
||||
"output_cost_per_image": 0.05,
|
||||
"source": "https://bfl.ai/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/images/edits"
|
||||
]
|
||||
},
|
||||
"black_forest_labs/flux-pro-1.0-expand": {
|
||||
"litellm_provider": "black_forest_labs",
|
||||
"mode": "image_edit",
|
||||
"output_cost_per_image": 0.05,
|
||||
"source": "https://bfl.ai/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/images/edits"
|
||||
]
|
||||
},
|
||||
"black_forest_labs/flux-pro-1.1": {
|
||||
"litellm_provider": "black_forest_labs",
|
||||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.04,
|
||||
"source": "https://bfl.ai/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/images/generations"
|
||||
]
|
||||
},
|
||||
"black_forest_labs/flux-pro-1.1-ultra": {
|
||||
"litellm_provider": "black_forest_labs",
|
||||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.06,
|
||||
"source": "https://bfl.ai/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/images/generations"
|
||||
]
|
||||
},
|
||||
"black_forest_labs/flux-dev": {
|
||||
"litellm_provider": "black_forest_labs",
|
||||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.025,
|
||||
"source": "https://bfl.ai/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/images/generations"
|
||||
]
|
||||
},
|
||||
"black_forest_labs/flux-pro": {
|
||||
"litellm_provider": "black_forest_labs",
|
||||
"mode": "image_generation",
|
||||
"output_cost_per_image": 0.05,
|
||||
"source": "https://bfl.ai/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/images/generations"
|
||||
]
|
||||
},
|
||||
"cerebras/llama-3.3-70b": {
|
||||
"input_cost_per_token": 8.5e-07,
|
||||
"litellm_provider": "cerebras",
|
||||
|
||||
@@ -69,6 +69,72 @@ if MCP_AVAILABLE:
|
||||
return server_auth
|
||||
return mcp_auth_header
|
||||
|
||||
async def _get_user_oauth_extra_headers(
|
||||
server,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
For OAuth2 servers, look up the user's stored access token and return it
|
||||
as extra_headers {"Authorization": "Bearer <token>"} so that it reaches
|
||||
the MCP server the same way the admin "Add MCP / Authorize and Fetch" flow does.
|
||||
Returns None for non-OAuth2 servers or when no credential is stored.
|
||||
"""
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
if getattr(server, "auth_type", None) != MCPAuth.oauth2:
|
||||
return None
|
||||
user_id = getattr(user_api_key_dict, "user_id", None)
|
||||
server_id = getattr(server, "server_id", None)
|
||||
if not user_id or not server_id:
|
||||
return None
|
||||
try:
|
||||
from litellm.proxy._experimental.mcp_server.db import (
|
||||
get_user_oauth_credential,
|
||||
)
|
||||
from litellm.proxy.utils import get_prisma_client_or_throw
|
||||
|
||||
prisma_client = get_prisma_client_or_throw(
|
||||
"Database not connected. Connect a database to use OAuth2 MCP tools."
|
||||
)
|
||||
cred = await get_user_oauth_credential(prisma_client, user_id, server_id)
|
||||
if cred and cred.get("access_token"):
|
||||
return {"Authorization": f"Bearer {cred['access_token']}"}
|
||||
except Exception:
|
||||
verbose_logger.debug("Failed to fetch OAuth credential", exc_info=True)
|
||||
return None
|
||||
|
||||
async def _get_bulk_user_oauth_headers(
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> Dict[str, Dict[str, str]]:
|
||||
"""
|
||||
Fetch ALL OAuth2 credentials for the current user in a single DB query and
|
||||
return a mapping of server_id → {"Authorization": "Bearer <token>"}.
|
||||
|
||||
This is the batch alternative to calling _get_user_oauth_extra_headers
|
||||
per-server inside a loop (N+1 DB queries).
|
||||
"""
|
||||
user_id = getattr(user_api_key_dict, "user_id", None)
|
||||
if not user_id:
|
||||
return {}
|
||||
try:
|
||||
from litellm.proxy._experimental.mcp_server.db import (
|
||||
list_user_oauth_credentials,
|
||||
)
|
||||
from litellm.proxy.utils import get_prisma_client_or_throw
|
||||
|
||||
prisma_client = get_prisma_client_or_throw(
|
||||
"Database not connected. Connect a database to use OAuth2 MCP tools."
|
||||
)
|
||||
creds = await list_user_oauth_credentials(prisma_client, user_id)
|
||||
return {
|
||||
c["server_id"]: {"Authorization": f"Bearer {c['access_token']}"}
|
||||
for c in creds
|
||||
if c.get("access_token") and c.get("server_id")
|
||||
}
|
||||
except Exception:
|
||||
verbose_logger.debug("Failed to bulk-fetch OAuth credentials", exc_info=True)
|
||||
return {}
|
||||
|
||||
def _create_tool_response_objects(tools, server_mcp_info):
|
||||
"""Helper function to create tool response objects."""
|
||||
return [
|
||||
@@ -162,11 +228,13 @@ if MCP_AVAILABLE:
|
||||
server_auth_header,
|
||||
raw_headers: Optional[Dict[str, str]] = None,
|
||||
user_api_key_auth: Optional[UserAPIKeyAuth] = None,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
):
|
||||
"""Helper function to get tools for a single server."""
|
||||
tools = await global_mcp_server_manager._get_tools_from_server(
|
||||
server=server,
|
||||
mcp_auth_header=server_auth_header,
|
||||
extra_headers=extra_headers,
|
||||
add_prefix=False,
|
||||
raw_headers=raw_headers,
|
||||
)
|
||||
@@ -294,6 +362,13 @@ if MCP_AVAILABLE:
|
||||
|
||||
# If server_id is specified, only query that specific server
|
||||
if server_id:
|
||||
# Resolve a server name to its UUID if needed (MCPConnectPicker passes
|
||||
# server_name strings, but allowed_server_ids_set contains UUIDs).
|
||||
if server_id not in allowed_server_ids:
|
||||
_resolved = global_mcp_server_manager.get_mcp_server_by_name(server_id)
|
||||
if _resolved is not None and _resolved.server_id in set(allowed_server_ids):
|
||||
server_id = _resolved.server_id
|
||||
|
||||
if server_id not in allowed_server_ids:
|
||||
_server = global_mcp_server_manager.get_mcp_server_by_id(server_id)
|
||||
if (
|
||||
@@ -333,6 +408,8 @@ if MCP_AVAILABLE:
|
||||
server_auth_header = _get_server_auth_header(
|
||||
server, mcp_server_auth_headers, mcp_auth_header
|
||||
)
|
||||
# Single-server request: targeted lookup is more efficient than a bulk fetch.
|
||||
user_oauth_extra_headers = await _get_user_oauth_extra_headers(server, user_api_key_dict)
|
||||
|
||||
try:
|
||||
list_tools_result = await _get_tools_for_single_server(
|
||||
@@ -340,6 +417,7 @@ if MCP_AVAILABLE:
|
||||
server_auth_header,
|
||||
raw_headers_from_request,
|
||||
user_api_key_dict,
|
||||
extra_headers=user_oauth_extra_headers,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_logger.exception(
|
||||
@@ -373,7 +451,10 @@ if MCP_AVAILABLE:
|
||||
},
|
||||
)
|
||||
|
||||
# Query all servers the user has access to
|
||||
# Query all servers the user has access to.
|
||||
# Bulk-fetch OAuth creds once so each per-server call below can
|
||||
# do an O(1) dict lookup instead of N individual DB queries.
|
||||
bulk_oauth_headers = await _get_bulk_user_oauth_headers(user_api_key_dict)
|
||||
errors = []
|
||||
for allowed_server_id in allowed_server_ids:
|
||||
server = global_mcp_server_manager.get_mcp_server_by_id(
|
||||
@@ -385,6 +466,7 @@ if MCP_AVAILABLE:
|
||||
server_auth_header = _get_server_auth_header(
|
||||
server, mcp_server_auth_headers, mcp_auth_header
|
||||
)
|
||||
user_oauth_extra_headers = bulk_oauth_headers.get(server.server_id)
|
||||
|
||||
try:
|
||||
tools_result = await _get_tools_for_single_server(
|
||||
@@ -392,6 +474,7 @@ if MCP_AVAILABLE:
|
||||
server_auth_header,
|
||||
raw_headers_from_request,
|
||||
user_api_key_dict,
|
||||
extra_headers=user_oauth_extra_headers,
|
||||
)
|
||||
list_tools_result.extend(tools_result)
|
||||
except Exception as e:
|
||||
@@ -505,6 +588,16 @@ if MCP_AVAILABLE:
|
||||
request, user_api_key_dict, server_id
|
||||
)
|
||||
|
||||
# Look up per-user OAuth headers for this server (mirrors list_tool_rest_api).
|
||||
user_oauth_extra_headers: Optional[Dict[str, str]] = None
|
||||
target_server = next(
|
||||
(s for s in allowed_mcp_servers if s.server_id == server_id), None
|
||||
)
|
||||
if target_server is not None:
|
||||
user_oauth_extra_headers = await _get_user_oauth_extra_headers(
|
||||
target_server, user_api_key_dict
|
||||
)
|
||||
|
||||
# Call execute_mcp_tool directly (permission checks already done)
|
||||
result = await execute_mcp_tool(
|
||||
name=tool_name,
|
||||
@@ -514,7 +607,7 @@ if MCP_AVAILABLE:
|
||||
user_api_key_auth=data.get("user_api_key_auth"),
|
||||
mcp_auth_header=data.get("mcp_auth_header"),
|
||||
mcp_server_auth_headers=data.get("mcp_server_auth_headers"),
|
||||
oauth2_headers=data.get("oauth2_headers"),
|
||||
oauth2_headers=user_oauth_extra_headers or data.get("oauth2_headers"),
|
||||
raw_headers=data.get("raw_headers"),
|
||||
litellm_logging_obj=data.get("litellm_logging_obj"),
|
||||
)
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
"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",
|
||||
"transport": "http",
|
||||
"url": "https://mcp.atlassian.com/v1/mcp",
|
||||
"env_vars": []
|
||||
},
|
||||
{
|
||||
|
||||
@@ -144,7 +144,10 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
||||
const [inputText, setInputText] = useState("");
|
||||
const [mcpPopoverOpen, setMcpPopoverOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [sidebarView, setSidebarView] = useState<"chats" | "apps" | "credentials">("chats");
|
||||
const _oauthReturn = searchParams?.get("mcpOauthReturn");
|
||||
const [sidebarView, setSidebarView] = useState<"chats" | "apps" | "credentials">(
|
||||
_oauthReturn === "apps" ? "apps" : "chats"
|
||||
);
|
||||
const [storageBannerDismissed, setStorageBannerDismissed] = useState(false);
|
||||
|
||||
// Comparison mode state (active when selectedModels.length > 1)
|
||||
@@ -172,6 +175,15 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
|
||||
renameConversation,
|
||||
} = useChatHistory(activeConversationId);
|
||||
|
||||
// Clean up the OAuth return param after it's been consumed
|
||||
useEffect(() => {
|
||||
if (_oauthReturn && typeof window !== "undefined") {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("mcpOauthReturn");
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Load models
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Spin, Input, Button, Skeleton } from "antd";
|
||||
import { SearchOutlined, ArrowLeftOutlined, RightOutlined, ToolOutlined, CheckCircleOutlined } from "@ant-design/icons";
|
||||
import { deleteMCPOAuthUserCredential, fetchMCPServers, getMCPOAuthUserCredentialStatus, listMCPTools } from "../networking";
|
||||
@@ -99,6 +99,16 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
|
||||
// OAuth2 connect state — tracks which server_ids have a stored user credential
|
||||
const [oauthConnected, setOauthConnected] = useState<Set<string>>(new Set());
|
||||
|
||||
// Refs keep the latest values for the auto-enable effect so it always reads
|
||||
// the current servers/selectedServers/onChange without needing them as
|
||||
// dependencies (which would cause the effect to fire on every render).
|
||||
const serversRef = useRef<MCPServer[]>([]);
|
||||
useEffect(() => { serversRef.current = servers; }, [servers]);
|
||||
const selectedServersRef = useRef<string[]>(selectedServers);
|
||||
useEffect(() => { selectedServersRef.current = selectedServers; }, [selectedServers]);
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
|
||||
|
||||
const nameOf = (s: MCPServer) => s.server_name ?? s.alias ?? s.server_id;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -155,9 +165,31 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
|
||||
return () => { cancelled = true; };
|
||||
}, [accessToken]);
|
||||
|
||||
// Auto-enable oauth2 servers for the current chat session when a valid
|
||||
// credential is detected (either on mount or after a fresh OAuth sign-in).
|
||||
// Uses refs for servers/selectedServers/onChange to avoid stale closures
|
||||
// without adding them as dependencies (which would re-fire on every render).
|
||||
useEffect(() => {
|
||||
if (oauthConnected.size === 0) return;
|
||||
const namesToAdd = serversRef.current
|
||||
.filter((s) => oauthConnected.has(s.server_id) && !selectedServersRef.current.includes(nameOf(s)))
|
||||
.map(nameOf);
|
||||
if (namesToAdd.length > 0) {
|
||||
onChangeRef.current([...selectedServersRef.current, ...namesToAdd]);
|
||||
}
|
||||
}, [oauthConnected]);
|
||||
|
||||
const handleToggle = async (serverName: string, checked: boolean, serverId?: string) => {
|
||||
if (!checked) {
|
||||
onChange(selectedServers.filter((s) => s !== serverName));
|
||||
// Also clear from oauthConnected so the auto-enable effect doesn't re-add it.
|
||||
if (serverId) {
|
||||
setOauthConnected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(serverId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
setTogglingOn((prev) => new Set(prev).add(serverName));
|
||||
@@ -169,7 +201,11 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
|
||||
message.warning(`Could not load tools for ${serverName}`);
|
||||
return;
|
||||
}
|
||||
onChange([...selectedServers, serverName]);
|
||||
// Use the ref so we read the most up-to-date list; guard against duplicates
|
||||
// that the oauthConnected effect may have already added while we awaited.
|
||||
if (!selectedServersRef.current.includes(serverName)) {
|
||||
onChange([...selectedServersRef.current, serverName]);
|
||||
}
|
||||
} catch {
|
||||
message.warning(`Could not load tools for ${serverName}`);
|
||||
} finally {
|
||||
@@ -283,6 +319,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
|
||||
// Ignore — credential may already be gone; update UI regardless.
|
||||
}
|
||||
setOauthConnected((prev) => { const n = new Set(prev); n.delete(detailServer.server_id); return n; });
|
||||
onChange(selectedServers.filter((s) => s !== name));
|
||||
}}
|
||||
style={{ borderRadius: 8, fontWeight: 600, height: 38, minWidth: 110 }}
|
||||
>
|
||||
@@ -292,7 +329,10 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
|
||||
<OAuth2ConnectButton
|
||||
server={detailServer}
|
||||
accessToken={accessToken}
|
||||
onConnect={(id) => setOauthConnected((prev) => new Set(prev).add(id))}
|
||||
onConnect={(id) => {
|
||||
setOauthConnected((prev) => new Set(prev).add(id));
|
||||
handleToggle(name, true, detailServer.server_id);
|
||||
}}
|
||||
variant="button"
|
||||
/>
|
||||
)
|
||||
@@ -517,7 +557,10 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
|
||||
<OAuth2ConnectButton
|
||||
server={server}
|
||||
accessToken={accessToken}
|
||||
onConnect={(id) => setOauthConnected((prev) => new Set(prev).add(id))}
|
||||
onConnect={(id) => {
|
||||
setOauthConnected((prev) => new Set(prev).add(id));
|
||||
handleToggle(nameOf(server), true, server.server_id);
|
||||
}}
|
||||
variant="badge"
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useEffect, useState, useMemo } from "react";
|
||||
import React, { useEffect, useRef, useState, useMemo } from "react";
|
||||
import { listMCPTools } from "../networking";
|
||||
import { MCPTool, MCPServer } from "../mcp_tools/types";
|
||||
import { Text } from "@tremor/react";
|
||||
import { Spin, Checkbox } from "antd";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { Spin, Radio } from "antd";
|
||||
import { useMCPServers } from "../../app/(dashboard)/hooks/mcpServers/useMCPServers";
|
||||
import McpCrudPermissionPanel from "../mcp_tools/McpCrudPermissionPanel";
|
||||
import { classifyToolOp } from "../../utils/mcpToolCrudClassification";
|
||||
|
||||
interface MCPToolPermissionsProps {
|
||||
accessToken: string;
|
||||
@@ -25,6 +26,15 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
||||
const [serverTools, setServerTools] = useState<Record<string, MCPTool[]>>({});
|
||||
const [loadingTools, setLoadingTools] = useState<Record<string, boolean>>({});
|
||||
const [toolErrors, setToolErrors] = useState<Record<string, string>>({});
|
||||
const [viewModes, setViewModes] = useState<Record<string, "crud" | "flat">>({});
|
||||
|
||||
// Keep a ref to the latest toolPermissions so async fetch callbacks always
|
||||
// read the current value and do not overwrite sibling servers' results when
|
||||
// multiple fetches complete out-of-order (stale-closure race condition).
|
||||
const toolPermissionsRef = useRef(toolPermissions);
|
||||
useEffect(() => {
|
||||
toolPermissionsRef.current = toolPermissions;
|
||||
}, [toolPermissions]);
|
||||
|
||||
// Filter servers based on selectedServers
|
||||
const servers = useMemo(() => {
|
||||
@@ -32,19 +42,31 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
||||
return allServers.filter((server: MCPServer) => selectedServers.includes(server.server_id));
|
||||
}, [allServers, selectedServers]);
|
||||
|
||||
// Fetch tools for a specific server
|
||||
const fetchToolsForServer = async (serverId: string) => {
|
||||
// Fetch tools for a specific server; applies delete-blocked-by-default for new servers.
|
||||
// `token` is passed explicitly so the closure never captures a stale accessToken.
|
||||
const fetchToolsForServer = async (serverId: string, token: string) => {
|
||||
setLoadingTools((prev) => ({ ...prev, [serverId]: true }));
|
||||
setToolErrors((prev) => ({ ...prev, [serverId]: "" }));
|
||||
|
||||
try {
|
||||
const response = await listMCPTools(accessToken, serverId);
|
||||
const response = await listMCPTools(token, serverId);
|
||||
|
||||
if (response.error) {
|
||||
setToolErrors((prev) => ({ ...prev, [serverId]: response.message || "Failed to fetch tools" }));
|
||||
setServerTools((prev) => ({ ...prev, [serverId]: [] }));
|
||||
} else {
|
||||
setServerTools((prev) => ({ ...prev, [serverId]: response.tools || [] }));
|
||||
const fetchedTools: MCPTool[] = response.tools || [];
|
||||
setServerTools((prev) => ({ ...prev, [serverId]: fetchedTools }));
|
||||
|
||||
// For servers that have no permissions stored yet, block delete tools by default.
|
||||
// Read latest permissions from the ref to avoid clobbering concurrent results.
|
||||
const latestPermissions = toolPermissionsRef.current;
|
||||
if (!latestPermissions[serverId] && fetchedTools.length > 0) {
|
||||
const nonDeleteTools = fetchedTools
|
||||
.filter((t) => classifyToolOp(t.name, t.description || "") !== "delete")
|
||||
.map((t) => t.name);
|
||||
onChange({ ...latestPermissions, [serverId]: nonDeleteTools });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error fetching tools for server ${serverId}:`, err);
|
||||
@@ -55,44 +77,29 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-fetch tools when servers change
|
||||
// Auto-fetch tools when servers or accessToken change
|
||||
useEffect(() => {
|
||||
servers.forEach((server) => {
|
||||
if (!serverTools[server.server_id] && !loadingTools[server.server_id]) {
|
||||
fetchToolsForServer(server.server_id);
|
||||
fetchToolsForServer(server.server_id, accessToken);
|
||||
}
|
||||
});
|
||||
}, [servers]);
|
||||
// fetchToolsForServer is defined in this render scope but receives `accessToken`
|
||||
// as an explicit argument, so it is safe to omit from deps here.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [servers, accessToken]);
|
||||
|
||||
// Handle tool selection
|
||||
const handleToolToggle = (serverId: string, toolName: string) => {
|
||||
const currentTools = toolPermissions[serverId] || [];
|
||||
const newTools = currentTools.includes(toolName)
|
||||
? currentTools.filter((name) => name !== toolName)
|
||||
: [...currentTools, toolName];
|
||||
|
||||
const updatedPermissions = {
|
||||
...toolPermissions,
|
||||
[serverId]: newTools,
|
||||
};
|
||||
onChange(updatedPermissions);
|
||||
const handleCrudPanelChange = (serverId: string, allowed: string[]) => {
|
||||
onChange({ ...toolPermissions, [serverId]: allowed });
|
||||
};
|
||||
|
||||
const handleSelectAll = (serverId: string) => {
|
||||
const tools = serverTools[serverId] || [];
|
||||
const newPermissions = {
|
||||
...toolPermissions,
|
||||
[serverId]: tools.map((t) => t.name),
|
||||
};
|
||||
onChange(newPermissions);
|
||||
onChange({ ...toolPermissions, [serverId]: tools.map((t) => t.name) });
|
||||
};
|
||||
|
||||
const handleDeselectAll = (serverId: string) => {
|
||||
const newPermissions = {
|
||||
...toolPermissions,
|
||||
[serverId]: [],
|
||||
};
|
||||
onChange(newPermissions);
|
||||
onChange({ ...toolPermissions, [serverId]: [] });
|
||||
};
|
||||
|
||||
if (selectedServers.length === 0) {
|
||||
@@ -107,6 +114,7 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
||||
const selectedTools = toolPermissions[server.server_id] || [];
|
||||
const isLoading = loadingTools[server.server_id];
|
||||
const error = toolErrors[server.server_id];
|
||||
const viewMode = viewModes[server.server_id] ?? "crud";
|
||||
|
||||
return (
|
||||
<div key={server.server_id} className="border rounded-lg bg-gray-50">
|
||||
@@ -117,38 +125,46 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
||||
{server.description && <Text className="text-sm text-gray-500">{server.description}</Text>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||
onClick={() => handleSelectAll(server.server_id)}
|
||||
disabled={disabled || isLoading}
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||
onClick={() => handleDeselectAll(server.server_id)}
|
||||
disabled={disabled || isLoading}
|
||||
>
|
||||
Deselect All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
onClick={() => {
|
||||
// Handle remove server if needed
|
||||
}}
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
</button>
|
||||
{!disabled && tools.length > 0 && (
|
||||
<Radio.Group
|
||||
value={viewMode}
|
||||
onChange={(e) =>
|
||||
setViewModes((prev) => ({ ...prev, [server.server_id]: e.target.value }))
|
||||
}
|
||||
size="small"
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
options={[
|
||||
{ label: "Risk Groups", value: "crud" },
|
||||
{ label: "Flat List", value: "flat" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{!disabled && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||
onClick={() => handleSelectAll(server.server_id)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||
onClick={() => handleDeselectAll(server.server_id)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Deselect All
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools */}
|
||||
<div className="p-4">
|
||||
<Text className="text-sm font-medium text-gray-700 mb-3">Available Tools</Text>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
@@ -165,23 +181,42 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool List - Compact */}
|
||||
{!isLoading && !error && tools.length > 0 && (
|
||||
{/* CRUD grouped view */}
|
||||
{!isLoading && !error && tools.length > 0 && viewMode === "crud" && (
|
||||
<McpCrudPermissionPanel
|
||||
tools={tools}
|
||||
value={!toolPermissions[server.server_id] ? undefined : selectedTools}
|
||||
onChange={(allowed) => handleCrudPanelChange(server.server_id, allowed)}
|
||||
readOnly={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Flat list view */}
|
||||
{!isLoading && !error && tools.length > 0 && viewMode === "flat" && (
|
||||
<div className="space-y-2">
|
||||
{tools.map((tool) => {
|
||||
const isSelected = selectedTools.includes(tool.name);
|
||||
|
||||
return (
|
||||
<div key={tool.name} className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleToolToggle(server.server_id, tool.name)}
|
||||
onChange={() => {
|
||||
if (disabled) return;
|
||||
const next = isSelected
|
||||
? selectedTools.filter((n) => n !== tool.name)
|
||||
: [...selectedTools, tool.name];
|
||||
handleCrudPanelChange(server.server_id, next);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Text className="font-medium text-gray-900">{tool.name}</Text>
|
||||
<Text className="text-sm text-gray-500">- {tool.description || "No description"}</Text>
|
||||
<Text className="text-sm text-gray-500">
|
||||
- {tool.description || "No description"}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* McpCrudPermissionPanel
|
||||
*
|
||||
* Displays MCP tools grouped by CRUD operation risk category.
|
||||
* Lets admins toggle an entire category (Read / Create / Update / Delete)
|
||||
* or individual tools within a category.
|
||||
*
|
||||
* The component is a drop-in replacement for a flat tool checkbox list.
|
||||
* Output is the same `string[]` of allowed tool names that the backend accepts.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Checkbox } from "antd";
|
||||
import { Text } from "@tremor/react";
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
|
||||
import {
|
||||
CrudOp,
|
||||
MCPToolEntry,
|
||||
CRUD_GROUP_META,
|
||||
groupToolsByCrud,
|
||||
} from "../../utils/mcpToolCrudClassification";
|
||||
|
||||
interface McpCrudPermissionPanelProps {
|
||||
/** List of tools available on this MCP server. */
|
||||
tools: MCPToolEntry[];
|
||||
/**
|
||||
* Currently allowed tool names.
|
||||
* `undefined` means "allow all" (no restriction stored yet).
|
||||
* An empty array means "allow none".
|
||||
*/
|
||||
value: string[] | undefined;
|
||||
/** Called whenever the allowed set changes. Always emits a concrete string[]. */
|
||||
onChange: (allowed: string[]) => void;
|
||||
readOnly?: boolean;
|
||||
/**
|
||||
* Optional search filter string. When set, only tools whose name or description
|
||||
* contain this string (case-insensitive) are shown. Group-level toggles still
|
||||
* operate on the complete group — not just the visible (filtered) subset.
|
||||
*/
|
||||
searchFilter?: string;
|
||||
}
|
||||
|
||||
const CRUD_ORDER: CrudOp[] = ["read", "create", "update", "delete", "unknown"];
|
||||
|
||||
const RISK_BADGE: Record<string, string> = {
|
||||
low: "bg-green-100 text-green-800",
|
||||
medium: "bg-yellow-100 text-yellow-800",
|
||||
high: "bg-red-100 text-red-800 font-semibold",
|
||||
unknown: "bg-gray-100 text-gray-700",
|
||||
};
|
||||
|
||||
const GROUP_BORDER: Record<CrudOp, string> = {
|
||||
read: "border-green-200",
|
||||
create: "border-blue-200",
|
||||
update: "border-yellow-200",
|
||||
delete: "border-red-300",
|
||||
unknown: "border-gray-200",
|
||||
};
|
||||
|
||||
const GROUP_HEADER_BG: Record<CrudOp, string> = {
|
||||
read: "bg-green-50",
|
||||
create: "bg-blue-50",
|
||||
update: "bg-yellow-50",
|
||||
delete: "bg-red-50",
|
||||
unknown: "bg-gray-50",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const McpCrudPermissionPanel: React.FC<McpCrudPermissionPanelProps> = ({
|
||||
tools,
|
||||
value,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
searchFilter = "",
|
||||
}) => {
|
||||
const [collapsed, setCollapsed] = useState<Record<CrudOp, boolean>>({
|
||||
read: false,
|
||||
create: false,
|
||||
update: false,
|
||||
delete: false,
|
||||
unknown: true,
|
||||
});
|
||||
|
||||
const grouped = useMemo(() => groupToolsByCrud(tools), [tools]);
|
||||
|
||||
/**
|
||||
* Derive the effective allowed set:
|
||||
* - `undefined` → all tools allowed
|
||||
* - We materialise it to a Set<string> for fast lookups.
|
||||
*/
|
||||
const effectiveAllowed: Set<string> = useMemo(() => {
|
||||
if (value === undefined) {
|
||||
return new Set(tools.map((t) => t.name));
|
||||
}
|
||||
return new Set(value);
|
||||
}, [value, tools]);
|
||||
|
||||
const isToolAllowed = (name: string) => effectiveAllowed.has(name);
|
||||
|
||||
const isGroupFullyAllowed = (op: CrudOp) => {
|
||||
const group = grouped[op];
|
||||
return group.length > 0 && group.every((t) => effectiveAllowed.has(t.name));
|
||||
};
|
||||
|
||||
const isGroupPartiallyAllowed = (op: CrudOp) => {
|
||||
const group = grouped[op];
|
||||
if (group.length === 0) return false;
|
||||
const allowedCount = group.filter((t) => effectiveAllowed.has(t.name)).length;
|
||||
return allowedCount > 0 && allowedCount < group.length;
|
||||
};
|
||||
|
||||
const toggleTool = (toolName: string) => {
|
||||
if (readOnly) return;
|
||||
const next = new Set(effectiveAllowed);
|
||||
if (next.has(toolName)) {
|
||||
next.delete(toolName);
|
||||
} else {
|
||||
next.add(toolName);
|
||||
}
|
||||
onChange(Array.from(next));
|
||||
};
|
||||
|
||||
const toggleGroup = (op: CrudOp, enable: boolean) => {
|
||||
if (readOnly) return;
|
||||
const next = new Set(effectiveAllowed);
|
||||
for (const tool of grouped[op]) {
|
||||
if (enable) {
|
||||
next.add(tool.name);
|
||||
} else {
|
||||
next.delete(tool.name);
|
||||
}
|
||||
}
|
||||
onChange(Array.from(next));
|
||||
};
|
||||
|
||||
const toggleCollapse = (op: CrudOp) => {
|
||||
setCollapsed((prev) => ({ ...prev, [op]: !prev[op] }));
|
||||
};
|
||||
|
||||
if (tools.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{CRUD_ORDER.map((op) => {
|
||||
const group = grouped[op];
|
||||
if (group.length === 0) return null;
|
||||
|
||||
// If a search filter is active and no tools in this group match, hide the
|
||||
// entire group — including its header — to avoid empty visual blocks.
|
||||
if (searchFilter) {
|
||||
const lf = searchFilter.toLowerCase();
|
||||
const hasMatch = group.some(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(lf) ||
|
||||
(t.description ?? "").toLowerCase().includes(lf)
|
||||
);
|
||||
if (!hasMatch) return null;
|
||||
}
|
||||
|
||||
const meta = CRUD_GROUP_META[op];
|
||||
const fullyAllowed = isGroupFullyAllowed(op);
|
||||
const partial = isGroupPartiallyAllowed(op);
|
||||
const isCollapsed = collapsed[op];
|
||||
|
||||
return (
|
||||
<div key={op} className={`rounded-lg border ${GROUP_BORDER[op]} overflow-hidden`}>
|
||||
{/* Group header */}
|
||||
<div className={`flex items-center justify-between px-4 py-3 ${GROUP_HEADER_BG[op]}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 flex-1 text-left"
|
||||
onClick={() => toggleCollapse(op)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRightIcon className="w-4 h-4 text-gray-500 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronDownIcon className="w-4 h-4 text-gray-500 flex-shrink-0" />
|
||||
)}
|
||||
<span className="font-semibold text-gray-900 text-sm">{meta.label}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${RISK_BADGE[meta.risk]}`}>
|
||||
{meta.risk === "high"
|
||||
? "High Risk"
|
||||
: meta.risk === "medium"
|
||||
? "Medium Risk"
|
||||
: meta.risk === "low"
|
||||
? "Safe"
|
||||
: "Unclassified"}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 ml-1">
|
||||
{group.filter((t) => effectiveAllowed.has(t.name)).length}/{group.length} allowed
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Text className="text-xs text-gray-500">
|
||||
{fullyAllowed ? "All on" : partial ? "Partial" : "All off"}
|
||||
</Text>
|
||||
{/* Checkbox supports `indeterminate`; Switch does not. */}
|
||||
<Checkbox
|
||||
checked={fullyAllowed}
|
||||
indeterminate={partial}
|
||||
onChange={(e) => toggleGroup(op, e.target.checked)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description row */}
|
||||
{!isCollapsed && (
|
||||
<div className="px-4 pt-2 pb-1 text-xs text-gray-500 bg-white border-b border-gray-100">
|
||||
{meta.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool list — searchFilter narrows display only; group toggles still cover all tools */}
|
||||
{!isCollapsed && (
|
||||
<div className="bg-white divide-y divide-gray-50">
|
||||
{group
|
||||
.filter((t) =>
|
||||
!searchFilter ||
|
||||
t.name.toLowerCase().includes(searchFilter.toLowerCase()) ||
|
||||
(t.description ?? "").toLowerCase().includes(searchFilter.toLowerCase())
|
||||
)
|
||||
.map((tool) => {
|
||||
const allowed = isToolAllowed(tool.name);
|
||||
return (
|
||||
<div
|
||||
key={tool.name}
|
||||
className={`flex items-start gap-3 px-4 py-2.5 transition-colors hover:bg-gray-50 ${
|
||||
!readOnly ? "cursor-pointer" : ""
|
||||
} ${allowed ? "" : "opacity-60"}`}
|
||||
onClick={() => toggleTool(tool.name)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={allowed}
|
||||
onChange={() => toggleTool(tool.name)}
|
||||
disabled={readOnly}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Text className="font-medium text-gray-900 text-sm">{tool.name}</Text>
|
||||
{tool.description && (
|
||||
<Text className="text-xs text-gray-500 mt-0.5 leading-snug">
|
||||
{tool.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs px-1.5 py-0.5 rounded flex-shrink-0 ${
|
||||
allowed ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{allowed ? "on" : "off"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default McpCrudPermissionPanel;
|
||||
@@ -128,7 +128,13 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
||||
server.mcp_access_groups?.some((g: any) => (typeof g === "string" ? g === group : g && g.name === group)),
|
||||
);
|
||||
}
|
||||
setFilteredServers(filtered);
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
if (!a.created_at && !b.created_at) return 0;
|
||||
if (!a.created_at) return 1;
|
||||
if (!b.created_at) return -1;
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
||||
});
|
||||
setFilteredServers(sorted);
|
||||
}, [serversWithHealth]);
|
||||
|
||||
// Handle team filter change
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Card, Title, Text } from "@tremor/react";
|
||||
import { ToolOutlined, CheckCircleOutlined, SearchOutlined, EditOutlined } from "@ant-design/icons";
|
||||
import { Badge, Spin, Checkbox, Input } from "antd";
|
||||
import { Badge, Spin, Checkbox, Input, Radio } from "antd";
|
||||
import { useTestMCPConnection } from "../../hooks/useTestMCPConnection";
|
||||
import McpCrudPermissionPanel from "./McpCrudPermissionPanel";
|
||||
|
||||
interface KeyTool {
|
||||
name: string;
|
||||
@@ -160,6 +161,7 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
||||
}) => {
|
||||
const previousToolsRef = useRef<ToolEntry[]>([]);
|
||||
const [toolSearchTerm, setToolSearchTerm] = useState("");
|
||||
const [viewMode, setViewMode] = useState<"crud" | "flat">("crud");
|
||||
const hasInitializedRef = useRef(false);
|
||||
const previousSuggestedToolNamesRef = useRef<string>("");
|
||||
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
|
||||
@@ -367,6 +369,19 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{tools.length > 0 && (
|
||||
<Radio.Group
|
||||
value={viewMode}
|
||||
onChange={(e) => setViewMode(e.target.value)}
|
||||
size="small"
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
options={[
|
||||
{ label: "Risk Groups", value: "crud" },
|
||||
{ label: "Flat List", value: "flat" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
@@ -436,7 +451,7 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
{/* Search box shared by both views */}
|
||||
<Input
|
||||
placeholder="Search tools by name or description..."
|
||||
prefix={<SearchOutlined className="text-gray-400" />}
|
||||
@@ -447,14 +462,26 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
||||
size="large"
|
||||
/>
|
||||
|
||||
{/* Tool list with checkboxes */}
|
||||
{filteredTools.length === 0 ? (
|
||||
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed">
|
||||
<SearchOutlined className="text-2xl mb-2" />
|
||||
<Text>No tools found matching "{toolSearchTerm}"</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* CRUD grouped view */}
|
||||
{viewMode === "crud" && (
|
||||
<McpCrudPermissionPanel
|
||||
tools={tools}
|
||||
searchFilter={toolSearchTerm}
|
||||
value={allowedTools.length === 0 ? undefined : allowedTools}
|
||||
onChange={(allowed) => onAllowedToolsChange(allowed)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Flat list view */}
|
||||
{viewMode === "flat" && (
|
||||
<>
|
||||
{filteredTools.length === 0 ? (
|
||||
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed">
|
||||
<SearchOutlined className="text-2xl mb-2" />
|
||||
<Text>No tools found matching "{toolSearchTerm}"</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{pinnedFiltered.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-1">
|
||||
@@ -533,7 +560,9 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -179,7 +179,9 @@ export const useUserMcpOAuthFlow = ({
|
||||
};
|
||||
|
||||
setStorage(FLOW_STATE_KEY, JSON.stringify(flowState));
|
||||
setStorage(RETURN_URL_KEY, window.location.href);
|
||||
const returnUrl = new URL(window.location.href);
|
||||
returnUrl.searchParams.set("mcpOauthReturn", "apps");
|
||||
setStorage(RETURN_URL_KEY, returnUrl.toString());
|
||||
|
||||
window.location.href = authorizeUrl;
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
export type CrudOp = "read" | "create" | "update" | "delete" | "unknown";
|
||||
|
||||
const DELETE_RE = /\b(delete|remove|destroy|purge|drop|erase|unlink)\b/i;
|
||||
const CREATE_RE = /\b(create|add|insert|new|post|submit|register|make|generate|write|upload)\b/i;
|
||||
const UPDATE_RE = /\b(update|edit|modify|change|patch|put|set|rename|move|transform)\b/i;
|
||||
const READ_RE = /\b(get|read|list|fetch|search|find|query|retrieve|show|view|check|describe|info)\b/i;
|
||||
|
||||
export interface MCPToolEntry {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies a tool by its name first; falls back to description only when
|
||||
* the name alone yields no match. This prevents incidental phrasing in
|
||||
* free-form descriptions (e.g. "removes noise from…") from promoting a safe
|
||||
* tool into a high-risk bucket.
|
||||
*
|
||||
* READ is checked before DELETE/UPDATE so that tools like `get_removed_entries`
|
||||
* or `list_deleted_items` — where the primary verb is a read operation — are
|
||||
* not silently blocked by the delete-by-default policy for new servers.
|
||||
*/
|
||||
export function classifyToolOp(name: string, description = ""): CrudOp {
|
||||
const nameLower = name.toLowerCase();
|
||||
if (READ_RE.test(nameLower)) return "read";
|
||||
if (DELETE_RE.test(nameLower)) return "delete";
|
||||
if (UPDATE_RE.test(nameLower)) return "update";
|
||||
if (CREATE_RE.test(nameLower)) return "create";
|
||||
|
||||
// Only consult description when the name is unrecognised.
|
||||
if (description) {
|
||||
const descLower = description.toLowerCase();
|
||||
if (READ_RE.test(descLower)) return "read";
|
||||
if (DELETE_RE.test(descLower)) return "delete";
|
||||
if (UPDATE_RE.test(descLower)) return "update";
|
||||
if (CREATE_RE.test(descLower)) return "create";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function groupToolsByCrud(tools: MCPToolEntry[]): Record<CrudOp, MCPToolEntry[]> {
|
||||
const groups: Record<CrudOp, MCPToolEntry[]> = {
|
||||
read: [],
|
||||
create: [],
|
||||
update: [],
|
||||
delete: [],
|
||||
unknown: [],
|
||||
};
|
||||
for (const tool of tools) {
|
||||
const op = classifyToolOp(tool.name, tool.description);
|
||||
groups[op].push(tool);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export const CRUD_GROUP_META: Record<
|
||||
CrudOp,
|
||||
{ label: string; description: string; risk: "low" | "medium" | "high" | "unknown" }
|
||||
> = {
|
||||
read: {
|
||||
label: "Read",
|
||||
description: "Safe operations — fetch, list, search. No side effects.",
|
||||
risk: "low",
|
||||
},
|
||||
create: {
|
||||
label: "Create",
|
||||
description: "Add new resources — insert, upload, register.",
|
||||
risk: "medium",
|
||||
},
|
||||
update: {
|
||||
label: "Update",
|
||||
description: "Modify existing resources — edit, patch, rename.",
|
||||
risk: "medium",
|
||||
},
|
||||
delete: {
|
||||
label: "Delete",
|
||||
description: "Destructive operations — remove, purge, destroy.",
|
||||
risk: "high",
|
||||
},
|
||||
unknown: {
|
||||
label: "Other",
|
||||
description: "Operations that could not be automatically classified.",
|
||||
risk: "unknown",
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user