mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-25 19:15:18 +02:00
feat: add MCP connector frontend UI and integration
This commit is contained in:
parent
92e7b3aef3
commit
792548b379
17 changed files with 948 additions and 41 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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]">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue