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:
Ishaan Jaff
2026-03-11 21:15:25 -07:00
committed by GitHub
parent 626d120873
commit b0aa71ed9b
11 changed files with 737 additions and 87 deletions
@@ -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"),
)
+2 -2
View File
@@ -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 &quot;{toolSearchTerm}&quot;</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 &quot;{toolSearchTerm}&quot;</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",
},
};