feat: add MCP connector frontend UI and integration

This commit is contained in:
Manoj Aggarwal 2026-01-13 13:46:17 -08:00
parent 92e7b3aef3
commit 792548b379
17 changed files with 948 additions and 41 deletions

View file

@ -68,6 +68,7 @@ export const ConnectorIndicator: FC = () => {
setEndDate,
setPeriodicEnabled,
setFrequencyMinutes,
setOtherMCPConnectorIds,
handleOpenChange,
handleTabChange,
handleScroll,
@ -239,6 +240,8 @@ export const ConnectorIndicator: FC = () => {
isSaving={isSaving}
isDisconnecting={isDisconnecting}
isIndexing={indexingConnectorIds.has(editingConnector.id)}
searchSpaceId={searchSpaceId?.toString()}
onOtherMCPConnectorsLoaded={setOtherMCPConnectorIds}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}

View file

@ -27,6 +27,16 @@ interface ConnectorCardProps {
onManage?: () => void;
}
/**
* Check if a connector type is indexable (has documents)
* MCP connectors are tools only and don't have indexable content
*/
function isIndexableConnector(connectorType?: string): boolean {
if (!connectorType) return true; // Default to true for unknown types
const nonIndexableTypes = ["MCP_CONNECTOR"];
return !nonIndexableTypes.includes(connectorType);
}
/**
* Extract a number from the active task message for display
* Looks for patterns like "45 indexed", "Processing 123", etc.
@ -135,7 +145,12 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
}
if (isConnected) {
// Show last indexed date for connected connectors
// For non-indexable connectors (like MCP), show description instead of index status
if (!isIndexableConnector(connectorType)) {
return description;
}
// Show last indexed date for connected indexable connectors
if (lastIndexedAt) {
return (
<span className="whitespace-nowrap text-[10px]">

View file

@ -0,0 +1,276 @@
"use client";
import { CheckCircle2, Server, XCircle } from "lucide-react";
import { type FC, useRef, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { EnumConnectorName } from "@/contracts/enums/connector";
import type { MCPServerConfig, MCPToolDefinition } from "@/contracts/types/mcp.types";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import type { ConnectFormProps } from "..";
const DEFAULT_CONFIG = `[
{
"name": "MCP Server 1",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/directory"],
"env": {},
"transport": "stdio"
}
]`;
interface MCPServerWithName extends MCPServerConfig {
name: string;
}
export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const [configJson, setConfigJson] = useState(DEFAULT_CONFIG);
const [jsonError, setJsonError] = useState<string | null>(null);
const [isTesting, setIsTesting] = useState(false);
const [testResults, setTestResults] = useState<Array<{
name: string;
status: "success" | "error";
message: string;
tools: MCPToolDefinition[];
}> | null>(null);
const parseConfigs = (): { configs: MCPServerWithName[] | null; error: string | null } => {
try {
const parsed = JSON.parse(configJson);
// Must be an array
if (!Array.isArray(parsed)) {
return {
configs: null,
error: "Configuration must be an array of MCP server objects",
};
}
if (parsed.length === 0) {
return {
configs: null,
error: "Array must contain at least one MCP server configuration",
};
}
// Validate each server config
const configs: MCPServerWithName[] = [];
for (let i = 0; i < parsed.length; i++) {
const server = parsed[i];
if (!server.name || typeof server.name !== "string") {
return {
configs: null,
error: `Server ${i + 1}: 'name' field is required and must be a string`,
};
}
if (!server.command || typeof server.command !== "string") {
return {
configs: null,
error: `Server ${i + 1} (${server.name}): 'command' field is required and must be a string`,
};
}
configs.push({
name: server.name,
command: server.command,
args: Array.isArray(server.args) ? server.args : [],
env: typeof server.env === "object" && server.env !== null ? server.env : {},
transport: server.transport || "stdio",
});
}
return { configs, error: null };
} catch (error) {
return {
configs: null,
error: error instanceof Error ? error.message : "Invalid JSON",
};
}
};
const handleConfigChange = (value: string) => {
setConfigJson(value);
if (jsonError) {
setJsonError(null);
}
};
const handleTestConnection = async () => {
const { configs, error } = parseConfigs();
if (!configs || error) {
setJsonError(error);
setTestResults([{
name: "Parse Error",
status: "error",
message: error || "Invalid configuration",
tools: [],
}]);
return;
}
setIsTesting(true);
setTestResults(null);
setJsonError(null);
const results: Array<{
name: string;
status: "success" | "error";
message: string;
tools: MCPToolDefinition[];
}> = [];
for (const config of configs) {
try {
const result = await connectorsApiService.testMCPConnection(config);
results.push({
name: config.name,
...result,
});
} catch (error) {
results.push({
name: config.name,
status: "error",
message: error instanceof Error ? error.message : "Failed to connect to MCP server",
tools: [],
});
}
}
setTestResults(results);
setIsTesting(false);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
const { configs, error } = parseConfigs();
if (!configs || error) {
setJsonError(error);
alert(error || "Invalid JSON configuration");
return;
}
isSubmittingRef.current = true;
try {
// Submit all servers
for (const config of configs) {
await onSubmit({
name: config.name,
connector_type: EnumConnectorName.MCP_CONNECTOR,
config: { server_config: config },
is_indexable: false,
is_active: true,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
});
}
} finally {
isSubmittingRef.current = false;
}
};
return (
<div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-start [&>svg]:relative [&>svg]:left-0 [&>svg]:top-1">
<Server className="h-4 w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">MCP Servers</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs pl-0!">
Connect to one or more MCP (Model Context Protocol) servers. Paste a JSON array of server configurations below.
</AlertDescription>
</div>
</Alert>
<form id="mcp-connect-form" onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-4 sm:p-6 space-y-4">
<div className="space-y-2">
<Label htmlFor="config">MCP Servers Configuration (JSON Array)</Label>
<Textarea
id="config"
value={configJson}
onChange={(e) => handleConfigChange(e.target.value)}
placeholder={DEFAULT_CONFIG}
rows={16}
className={`font-mono text-xs ${jsonError ? "border-red-500" : ""}`}
/>
{jsonError && (
<p className="text-xs text-red-500">JSON Error: {jsonError}</p>
)}
<p className="text-[10px] sm:text-xs text-muted-foreground">
Paste an array of MCP server configurations. Each object must have: name, command, args (optional), env (optional), transport (optional).
</p>
</div>
<div className="pt-4">
<Button
type="button"
onClick={handleTestConnection}
disabled={isTesting}
variant="outline"
className="w-full"
>
{isTesting ? "Testing All Servers..." : "Test All Connections"}
</Button>
</div>
{testResults && testResults.length > 0 && (
<div className="space-y-3">
{testResults.map((result, index) => (
<Alert
key={index}
className={
result.status === "success"
? "border-green-500/50 bg-green-500/10"
: "border-red-500/50 bg-red-500/10"
}
>
{result.status === "success" ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<XCircle className="h-4 w-4 text-red-500" />
)}
<div>
<AlertTitle className="text-sm">
{result.name}: {result.status === "success" ? "Connected" : "Failed"}
</AlertTitle>
<AlertDescription className="text-xs">
{result.message}
{result.status === "success" && result.tools.length > 0 && (
<div className="mt-2">
<p className="font-semibold mb-1">
Found {result.tools.length} tools:
</p>
<ul className="list-disc list-inside space-y-1">
{result.tools.map((tool, i) => (
<li key={i} className="text-xs">
<strong>{tool.name}</strong>: {tool.description}
</li>
))}
</ul>
</div>
)}
</AlertDescription>
</div>
</Alert>
))}
</div>
)}
</div>
</form>
</div>
);
};

View file

@ -6,6 +6,7 @@ import { ElasticsearchConnectForm } from "./components/elasticsearch-connect-for
import { GithubConnectForm } from "./components/github-connect-form";
import { LinkupApiConnectForm } from "./components/linkup-api-connect-form";
import { LumaConnectForm } from "./components/luma-connect-form";
import { MCPConnectForm } from "./components/mcp-connect-form";
import { SearxngConnectForm } from "./components/searxng-connect-form";
import { TavilyApiConnectForm } from "./components/tavily-api-connect-form";
@ -15,6 +16,7 @@ export interface ConnectFormProps {
connector_type: string;
config: Record<string, unknown>;
is_indexable: boolean;
is_active: boolean;
last_indexed_at: null;
periodic_indexing_enabled: boolean;
indexing_frequency_minutes: number | null;
@ -54,6 +56,8 @@ export function getConnectFormComponent(connectorType: string): ConnectFormCompo
return LumaConnectForm;
case "CIRCLEBACK_CONNECTOR":
return CirclebackConnectForm;
case "MCP_CONNECTOR":
return MCPConnectForm;
// Add other connector types here as needed
default:
return null;

View file

@ -0,0 +1,310 @@
"use client";
import { CheckCircle2, Server, XCircle } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { MCPServerConfig, MCPToolDefinition } from "@/contracts/types/mcp.types";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import type { ConnectorConfigProps } from "../index";
interface MCPConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void;
searchSpaceId?: string;
onOtherMCPConnectorsLoaded?: (connectorIds: number[]) => void;
}
export const MCPConfig: FC<MCPConfigProps> = ({ connector, onConfigChange, onNameChange, searchSpaceId, onOtherMCPConnectorsLoaded }) => {
const [name, setName] = useState<string>("MCPs");
const [configJson, setConfigJson] = useState("");
const [jsonError, setJsonError] = useState<string | null>(null);
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<{
status: "success" | "error";
message: string;
tools: MCPToolDefinition[];
} | null>(null);
const [allMCPConnectors, setAllMCPConnectors] = useState<any[]>([]);
// Load all MCP connectors for this search space
useEffect(() => {
const loadAllMCPConnectors = async () => {
if (!searchSpaceId) return;
try {
const connectors = await connectorsApiService.getConnectors({
queryParams: { search_space_id: parseInt(searchSpaceId, 10) }
});
const mcpConnectors = connectors.filter((c: any) => c.connector_type === "MCP_CONNECTOR");
setAllMCPConnectors(mcpConnectors);
// Notify parent about other MCP connectors that should be deleted on save
const otherConnectorIds = mcpConnectors
.filter((c: any) => c.id !== connector.id)
.map((c: any) => c.id);
if (onOtherMCPConnectorsLoaded && otherConnectorIds.length > 0) {
onOtherMCPConnectorsLoaded(otherConnectorIds);
}
// Collect all server configs from all MCP connectors
const allServerConfigs: MCPServerConfig[] = [];
for (const mcpConn of mcpConnectors) {
const serverConfigs = mcpConn.config?.server_configs as MCPServerConfig[] | undefined;
if (serverConfigs && Array.isArray(serverConfigs)) {
allServerConfigs.push(...serverConfigs);
} else {
// Fallback to single server_config
const serverConfig = mcpConn.config?.server_config as MCPServerConfig | undefined;
if (serverConfig) {
allServerConfigs.push(serverConfig);
}
}
}
if (allServerConfigs.length > 0) {
setConfigJson(JSON.stringify(allServerConfigs, null, 2));
} else {
setConfigJson(JSON.stringify([{
command: "",
args: [],
env: {},
transport: "stdio",
}], null, 2));
}
} catch (error) {
console.error("Failed to load MCP connectors:", error);
}
};
loadAllMCPConnectors();
}, [searchSpaceId]);
const handleNameChange = (value: string) => {
setName(value);
if (onNameChange) {
onNameChange(value);
}
};
const parseConfig = (): MCPServerConfig[] | null => {
try {
const parsed = JSON.parse(configJson);
// Handle both single object and array
const configs = Array.isArray(parsed) ? parsed : [parsed];
// Validate each config
const validConfigs: MCPServerConfig[] = [];
for (let i = 0; i < configs.length; i++) {
const cfg = configs[i];
if (!cfg.command || typeof cfg.command !== "string") {
setJsonError(`Config ${i + 1}: 'command' field is required and must be a string`);
return null;
}
validConfigs.push({
command: cfg.command,
args: cfg.args || [],
env: cfg.env || {},
transport: cfg.transport || "stdio",
});
}
setJsonError(null);
return validConfigs;
} catch (error) {
setJsonError(error instanceof Error ? error.message : "Invalid JSON");
return null;
}
};
const handleConfigChange = (value: string) => {
setConfigJson(value);
// Clear error when user starts typing
if (jsonError) {
setJsonError(null);
}
// Try to parse and update parent if valid
try {
const parsed = JSON.parse(value);
const configs = Array.isArray(parsed) ? parsed : [parsed];
// Validate each config
const validConfigs: MCPServerConfig[] = [];
for (const cfg of configs) {
if (cfg.command && typeof cfg.command === "string") {
validConfigs.push({
command: cfg.command,
args: cfg.args || [],
env: cfg.env || {},
transport: cfg.transport || "stdio",
});
}
}
// Update parent if we have valid configs
if (validConfigs.length > 0 && onConfigChange) {
onConfigChange({ server_configs: validConfigs });
}
} catch {
// Ignore parse errors while typing - don't update parent with invalid config
}
};
const handleTestConnection = async () => {
const serverConfigs = parseConfig();
if (!serverConfigs || serverConfigs.length === 0) {
setTestResult({
status: "error",
message: jsonError || "Invalid configuration",
tools: [],
});
return;
}
// Update parent with the config array
if (onConfigChange) {
onConfigChange({ server_configs: serverConfigs });
}
setIsTesting(true);
setTestResult(null);
try {
// Test all servers and collect results
const allTools: MCPToolDefinition[] = [];
const errors: string[] = [];
for (const serverConfig of serverConfigs) {
try {
const result = await connectorsApiService.testMCPConnection(serverConfig);
if (result.status === "success") {
allTools.push(...result.tools);
} else {
errors.push(`${serverConfig.command}: ${result.message}`);
}
} catch (error) {
errors.push(`${serverConfig.command}: ${error instanceof Error ? error.message : "Failed to connect"}`);
}
}
if (errors.length === 0) {
setTestResult({
status: "success",
message: `Successfully connected to ${serverConfigs.length} server(s)`,
tools: allTools,
});
} else if (allTools.length > 0) {
setTestResult({
status: "success",
message: `Partially successful. Errors: ${errors.join("; ")}`,
tools: allTools,
});
} else {
setTestResult({
status: "error",
message: errors.join("; "),
tools: [],
});
}
} catch (error) {
setTestResult({
status: "error",
message: error instanceof Error ? error.message : "Failed to connect to MCP servers",
tools: [],
});
} finally {
setIsTesting(false);
}
};
return (
<div className="space-y-6">
{/* Server Configuration */}
<div className="space-y-4">
<h3 className="font-medium text-sm sm:text-base flex items-center gap-2">
<Server className="h-4 w-4" />
Server Configuration
</h3>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-4">
<div className="space-y-2">
<Textarea
value={configJson}
onChange={(e) => handleConfigChange(e.target.value)}
rows={12}
className={`font-mono text-xs border-slate-400/20 focus-visible:border-slate-400/40 ${
jsonError ? "border-red-500" : ""
}`}
/>
{jsonError && (
<p className="text-xs text-red-500">
JSON Error: {jsonError}
</p>
)}
<p className="text-[10px] sm:text-xs text-muted-foreground">
Edit your MCP server configurations (array format). Each server requires: command, args, env, transport.
</p>
</div>
{/* Test Connection */}
<div className="pt-4">
<Button
type="button"
onClick={handleTestConnection}
disabled={isTesting}
variant="outline"
className="w-full"
>
{isTesting ? "Testing..." : "Test Connection"}
</Button>
</div>
{/* Test Result */}
{testResult && (
<Alert
className={
testResult.status === "success"
? "border-green-500/50 bg-green-500/10"
: "border-red-500/50 bg-red-500/10"
}
>
{testResult.status === "success" ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<XCircle className="h-4 w-4 text-red-500" />
)}
<div>
<AlertTitle className="text-sm">
{testResult.status === "success" ? "Connection Successful" : "Connection Failed"}
</AlertTitle>
<AlertDescription className="text-xs">
{testResult.message}
{testResult.status === "success" && testResult.tools.length > 0 && (
<div className="mt-2">
<p className="font-semibold mb-1">
Found {testResult.tools.length} tools:
</p>
<ul className="list-disc list-inside space-y-1">
{testResult.tools.map((tool, i) => (
<li key={i} className="text-xs">
<strong>{tool.name}</strong>: {tool.description}
</li>
))}
</ul>
</div>
)}
</AlertDescription>
</div>
</Alert>
)}
</div>
</div>
</div>
);
};

View file

@ -14,6 +14,7 @@ import { GoogleDriveConfig } from "./components/google-drive-config";
import { JiraConfig } from "./components/jira-config";
import { LinkupApiConfig } from "./components/linkup-api-config";
import { LumaConfig } from "./components/luma-config";
import { MCPConfig } from "./components/mcp-config";
import { SearxngConfig } from "./components/searxng-config";
import { SlackConfig } from "./components/slack-config";
import { TavilyApiConfig } from "./components/tavily-api-config";
@ -24,6 +25,8 @@ export interface ConnectorConfigProps {
connector: SearchSourceConnector;
onConfigChange?: (config: Record<string, unknown>) => void;
onNameChange?: (name: string) => void;
searchSpaceId?: string;
onOtherMCPConnectorsLoaded?: (connectorIds: number[]) => void;
}
export type ConnectorConfigComponent = FC<ConnectorConfigProps>;
@ -69,6 +72,8 @@ export function getConnectorConfigComponent(
return LumaConfig;
case "CIRCLEBACK_CONNECTOR":
return CirclebackConfig;
case "MCP_CONNECTOR":
return MCPConfig;
// OAuth connectors (Gmail, Calendar, Airtable, Notion) and others don't need special config UI
default:
return null;

View file

@ -56,6 +56,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
GITHUB_CONNECTOR: "github-connect-form",
LUMA_CONNECTOR: "luma-connect-form",
CIRCLEBACK_CONNECTOR: "circleback-connect-form",
MCP_CONNECTOR: "mcp-connect-form",
};
const formId = formIdMap[connectorType];
if (formId) {

View file

@ -19,6 +19,8 @@ interface ConnectorEditViewProps {
isSaving: boolean;
isDisconnecting: boolean;
isIndexing?: boolean;
searchSpaceId?: string;
onOtherMCPConnectorsLoaded?: (connectorIds: number[]) => void;
onStartDateChange: (date: Date | undefined) => void;
onEndDateChange: (date: Date | undefined) => void;
onPeriodicEnabledChange: (enabled: boolean) => void;
@ -40,6 +42,8 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
isSaving,
isDisconnecting,
isIndexing = false,
searchSpaceId,
onOtherMCPConnectorsLoaded,
onStartDateChange,
onEndDateChange,
onPeriodicEnabledChange,
@ -149,7 +153,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
</div>
<div className="flex-1 min-w-0">
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight text-wrap whitespace-normal wrap-break-word">
{connector.name}
{connector.connector_type === "MCP_CONNECTOR" ? "MCPs" : connector.name}
</h2>
<p className="text-xs sm:text-base text-muted-foreground mt-1">
Manage your connector settings and sync configuration
@ -197,6 +201,8 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
connector={connector}
onConfigChange={onConfigChange}
onNameChange={onNameChange}
searchSpaceId={searchSpaceId}
onOtherMCPConnectorsLoaded={onOtherMCPConnectorsLoaded}
/>
)}

View file

@ -160,6 +160,12 @@ export const OTHER_CONNECTORS = [
description: "Receive meeting notes, transcripts",
connectorType: EnumConnectorName.CIRCLEBACK_CONNECTOR,
},
{
id: "mcp-connector",
title: "MCPs",
description: "Connect to MCP servers for AI tools",
connectorType: EnumConnectorName.MCP_CONNECTOR,
},
] as const;
// Re-export IndexingConfigState from schemas for backward compatibility

View file

@ -68,6 +68,7 @@ export const useConnectorDialog = () => {
const [isDisconnecting, setIsDisconnecting] = useState(false);
const [connectorConfig, setConnectorConfig] = useState<Record<string, unknown> | null>(null);
const [connectorName, setConnectorName] = useState<string | null>(null);
const [otherMCPConnectorIds, setOtherMCPConnectorIds] = useState<number[]>([]);
// Connect mode state (for non-OAuth connectors)
const [connectingConnectorType, setConnectingConnectorType] = useState<string | null>(null);
@ -421,6 +422,7 @@ export const useConnectorDialog = () => {
connector_type: EnumConnectorName.WEBCRAWLER_CONNECTOR,
config: {},
is_indexable: true,
is_active: true,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
@ -522,17 +524,18 @@ export const useConnectorDialog = () => {
data: {
...connectorData,
connector_type: connectorData.connector_type as EnumConnectorName,
next_scheduled_at: connectorData.next_scheduled_at as string | null,
},
queryParams: {
search_space_id: searchSpaceId,
},
});
is_active: true,
next_scheduled_at: connectorData.next_scheduled_at as string | null,
},
queryParams: {
search_space_id: searchSpaceId,
},
});
// Refetch connectors to get the new one
const result = await refetchAllConnectors();
if (result.data) {
const connector = result.data.find(
// Refetch connectors to get the new one
const result = await refetchAllConnectors();
if (result.data) {
const connector = result.data.find(
(c: SearchSourceConnector) => c.id === newConnector.id
);
if (connector) {
@ -1009,7 +1012,7 @@ export const useConnectorDialog = () => {
// Handle saving connector changes
const handleSaveConnector = useCallback(
async (refreshConnectors: () => void) => {
if (!editingConnector || !searchSpaceId) return;
if (!editingConnector || !searchSpaceId || isSaving) return;
// Validate date range (skip for Google Drive which uses folder selection, Webcrawler which uses config, and non-indexable connectors)
if (
@ -1044,6 +1047,21 @@ export const useConnectorDialog = () => {
const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined;
const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined;
// For MCP connectors, delete other MCP connectors first (consolidate all into one)
if (editingConnector.connector_type === "MCP_CONNECTOR" && otherMCPConnectorIds.length > 0) {
// Silently delete other MCP connectors without showing toasts
await Promise.all(
otherMCPConnectorIds.map((id) =>
deleteConnector({
id,
}).catch(() => {
// Silently ignore errors for individual deletions
})
)
);
setOtherMCPConnectorIds([]);
}
// Update connector with periodic sync settings, config changes, and name
// Note: Periodic sync is disabled for Google Drive connectors and non-indexable connectors
const frequency =
@ -1148,11 +1166,16 @@ export const useConnectorDialog = () => {
);
}
toast.success(`${editingConnector.name} updated successfully`, {
description: periodicEnabled
? `Periodic sync ${frequency ? `enabled every ${getFrequencyLabel(frequencyMinutes)}` : "enabled"}. ${indexingDescription}`
: indexingDescription,
});
toast.success(
editingConnector.connector_type === "MCP_CONNECTOR"
? "MCPs updated successfully"
: `${editingConnector.name} updated successfully`,
{
description: periodicEnabled
? `Periodic sync ${frequency ? `enabled every ${getFrequencyLabel(frequencyMinutes)}` : "enabled"}. ${indexingDescription}`
: indexingDescription,
}
);
// Update URL - the effect will handle closing the modal and clearing state
const url = new URL(window.location.href);
@ -1176,6 +1199,7 @@ export const useConnectorDialog = () => {
[
editingConnector,
searchSpaceId,
isSaving,
startDate,
endDate,
indexConnector,
@ -1186,6 +1210,8 @@ export const useConnectorDialog = () => {
router,
connectorConfig,
connectorName,
otherMCPConnectorIds,
deleteConnector,
]
);
@ -1207,7 +1233,11 @@ export const useConnectorDialog = () => {
editingConnector.id
);
toast.success(`${editingConnector.name} disconnected successfully`);
toast.success(
editingConnector.connector_type === "MCP_CONNECTOR"
? "MCPs disconnected successfully"
: `${editingConnector.name} disconnected successfully`
);
// Update URL - the effect will handle closing the modal and clearing state
const url = new URL(window.location.href);
@ -1375,6 +1405,7 @@ export const useConnectorDialog = () => {
setPeriodicEnabled,
setFrequencyMinutes,
setConnectorName,
setOtherMCPConnectorIds,
// Handlers
handleOpenChange,

View file

@ -4,12 +4,15 @@ import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } f
import { ArrowRight, Cable, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import type { FC } from "react";
import { useState } from "react";
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { TabsContent } from "@/components/ui/tabs";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import { cn } from "@/lib/utils";
import { OAUTH_CONNECTORS } from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
@ -28,6 +31,14 @@ interface ActiveConnectorsTabProps {
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
}
/**
* Check if a connector type is indexable
*/
function isIndexableConnector(connectorType: string): boolean {
const nonIndexableTypes = ["MCP_CONNECTOR"];
return !nonIndexableTypes.includes(connectorType);
}
export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
searchQuery,
hasSources,
@ -112,9 +123,12 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
// Get OAuth connector types set for quick lookup
const oauthConnectorTypes = new Set<string>(OAUTH_CONNECTORS.map((c) => c.connectorType));
// Separate OAuth and non-OAuth connectors
// Separate OAuth, MCP, and other non-OAuth connectors
const oauthConnectors = connectors.filter((c) => oauthConnectorTypes.has(c.connector_type));
const nonOauthConnectors = connectors.filter((c) => !oauthConnectorTypes.has(c.connector_type));
const mcpConnectors = connectors.filter((c) => c.connector_type === "MCP_CONNECTOR");
const nonOauthConnectors = connectors.filter(
(c) => !oauthConnectorTypes.has(c.connector_type) && c.connector_type !== "MCP_CONNECTOR"
);
// Group OAuth connectors by type
const oauthConnectorsByType = oauthConnectors.reduce(
@ -165,8 +179,17 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
);
});
// Check if MCPs match search query
const showMCPs =
mcpConnectors.length > 0 &&
(!searchQuery ||
"mcps".includes(searchQuery.toLowerCase()) ||
"model context protocol".includes(searchQuery.toLowerCase()));
const hasActiveConnectors =
filteredOAuthConnectorTypes.length > 0 || filteredNonOAuthConnectors.length > 0;
filteredOAuthConnectorTypes.length > 0 ||
filteredNonOAuthConnectors.length > 0 ||
showMCPs;
return (
<TabsContent value="active" className="m-0">
@ -229,14 +252,20 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
</p>
) : (
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap">
{mostRecentLastIndexed
? `Last indexed: ${formatLastIndexedDate(mostRecentLastIndexed)}`
: "Never indexed"}
</p>
{isIndexableConnector(connectorType)
? mostRecentLastIndexed
? `Last indexed: ${formatLastIndexedDate(mostRecentLastIndexed)}`
: "Never indexed"
: "Active"}
</p>
)}
<p className="text-[10px] text-muted-foreground mt-0.5 flex items-center gap-1.5">
<span>{formatDocumentCount(documentCount)}</span>
<span className="text-muted-foreground/50"></span>
{isIndexableConnector(connectorType) && (
<>
<span>{formatDocumentCount(documentCount)}</span>
<span className="text-muted-foreground/50"></span>
</>
)}
<span>
{accountCount} {accountCount === 1 ? "Account" : "Accounts"}
</span>
@ -254,6 +283,44 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
);
})}
{/* MCP Connectors - Single Grouped Card */}
{showMCPs && (
<div
className={cn(
"flex items-center gap-4 p-4 rounded-xl border border-border transition-all",
"hover:bg-slate-400/10 dark:hover:bg-white/10"
)}
>
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-lg border shrink-0",
"bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
)}
>
{getConnectorIcon("MCP_CONNECTOR", "size-6")}
</div>
<div className="flex-1 min-w-0">
<p className="text-[14px] font-semibold leading-tight truncate">MCPs</p>
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap">
Active
</p>
<p className="text-[10px] text-muted-foreground mt-0.5">
{mcpConnectors.length} {mcpConnectors.length === 1 ? "Server" : "Servers"}
</p>
</div>
<Button
variant="secondary"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80 shrink-0"
onClick={
onManage && mcpConnectors[0] ? () => onManage(mcpConnectors[0]) : undefined
}
>
Manage
</Button>
</div>
)}
{/* Non-OAuth Connectors - Individual Cards */}
{filteredNonOAuthConnectors.map((connector) => {
const isIndexing = indexingConnectorIds.has(connector.id);
@ -264,7 +331,6 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
connector.connector_type,
documentTypeCounts
);
return (
<div
key={`connector-${connector.id}`}
@ -286,9 +352,11 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
{getConnectorIcon(connector.connector_type, "size-6")}
</div>
<div className="flex-1 min-w-0">
<p className="text-[14px] font-semibold leading-tight truncate">
{connector.name}
</p>
<div className="flex items-center gap-2">
<p className="text-[14px] font-semibold leading-tight">
{connector.name}
</p>
</div>
{isIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
@ -301,14 +369,18 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
</p>
) : (
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap">
{connector.last_indexed_at
? `Last indexed: ${formatLastIndexedDate(connector.last_indexed_at)}`
: "Never indexed"}
{isIndexableConnector(connector.connector_type)
? connector.last_indexed_at
? `Last indexed: ${formatLastIndexedDate(connector.last_indexed_at)}`
: "Never indexed"
: "Active"}
</p>
)}
{isIndexableConnector(connector.connector_type) && (
<p className="text-[10px] text-muted-foreground mt-0.5">
{formatDocumentCount(documentCount)}
</p>
)}
<p className="text-[10px] text-muted-foreground mt-0.5">
{formatDocumentCount(documentCount)}
</p>
</div>
<Button
variant="secondary"

View file

@ -23,6 +23,14 @@ interface ConnectorAccountsListViewProps {
isConnecting?: boolean;
}
/**
* Check if a connector type is indexable
*/
function isIndexableConnector(connectorType: string): boolean {
const nonIndexableTypes = ["MCP_CONNECTOR"];
return !nonIndexableTypes.includes(connectorType);
}
/**
* Format last indexed date with contextual messages
*/
@ -177,9 +185,11 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
</p>
) : (
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap truncate">
{connector.last_indexed_at
? `Last indexed: ${formatLastIndexedDate(connector.last_indexed_at)}`
: "Never indexed"}
{isIndexableConnector(connector.connector_type)
? connector.last_indexed_at
? `Last indexed: ${formatLastIndexedDate(connector.last_indexed_at)}`
: "Never indexed"
: "Active"}
</p>
)}
</div>