Merge pull request #705 from manojag115/feat/mcp-connector-frontend

(frontend) - Add ability to add MCP servers as connectors
This commit is contained in:
Rohan Verma 2026-01-18 22:14:20 -08:00 committed by GitHub
commit a8890b1c28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1474 additions and 156 deletions

View file

@ -4,6 +4,7 @@ This module provides a client for communicating with MCP servers via stdio trans
It handles server lifecycle management, tool discovery, and tool execution.
"""
import asyncio
import logging
import os
from contextlib import asynccontextmanager
@ -14,6 +15,11 @@ from mcp.client.stdio import StdioServerParameters, stdio_client
logger = logging.getLogger(__name__)
# Retry configuration
MAX_RETRIES = 3
RETRY_DELAY = 1.0 # seconds
RETRY_BACKOFF = 2.0 # exponential backoff multiplier
class MCPClient:
"""Client for communicating with an MCP server."""
@ -35,44 +41,86 @@ class MCPClient:
self.session: ClientSession | None = None
@asynccontextmanager
async def connect(self):
async def connect(self, max_retries: int = MAX_RETRIES):
"""Connect to the MCP server and manage its lifecycle.
Args:
max_retries: Maximum number of connection retry attempts
Yields:
ClientSession: Active MCP session for making requests
Raises:
RuntimeError: If all connection attempts fail
"""
try:
# Merge env vars with current environment
server_env = os.environ.copy()
server_env.update(self.env)
last_error = None
delay = RETRY_DELAY
# Create server parameters with env
server_params = StdioServerParameters(
command=self.command, args=self.args, env=server_env
)
for attempt in range(max_retries):
try:
# Merge env vars with current environment
server_env = os.environ.copy()
server_env.update(self.env)
# Spawn server process and create session
# Note: Cannot combine these context managers because ClientSession
# needs the read/write streams from stdio_client
async with stdio_client(server=server_params) as (read, write): # noqa: SIM117
async with ClientSession(read, write) as session:
# Initialize the connection
await session.initialize()
self.session = session
logger.info(
"Connected to MCP server: %s %s",
self.command,
" ".join(self.args),
# Create server parameters with env
server_params = StdioServerParameters(
command=self.command, args=self.args, env=server_env
)
# Spawn server process and create session
# Note: Cannot combine these context managers because ClientSession
# needs the read/write streams from stdio_client
async with stdio_client(server=server_params) as (read, write): # noqa: SIM117
async with ClientSession(read, write) as session:
# Initialize the connection
await session.initialize()
self.session = session
if attempt > 0:
logger.info(
"Connected to MCP server on attempt %d: %s %s",
attempt + 1,
self.command,
" ".join(self.args),
)
else:
logger.info(
"Connected to MCP server: %s %s",
self.command,
" ".join(self.args),
)
yield session
return # Success, exit retry loop
except Exception as e:
last_error = e
if attempt < max_retries - 1:
logger.warning(
"MCP server connection failed (attempt %d/%d): %s. Retrying in %.1fs...",
attempt + 1,
max_retries,
e,
delay,
)
yield session
await asyncio.sleep(delay)
delay *= RETRY_BACKOFF # Exponential backoff
else:
logger.error(
"Failed to connect to MCP server after %d attempts: %s",
max_retries,
e,
exc_info=True,
)
finally:
self.session = None
except Exception as e:
logger.error("Failed to connect to MCP server: %s", e, exc_info=True)
raise
finally:
self.session = None
logger.info("Disconnected from MCP server: %s", self.command)
# All retries exhausted
error_msg = f"Failed to connect to MCP server '{self.command}' after {max_retries} attempts"
if last_error:
error_msg += f": {last_error}"
logger.error(error_msg)
raise RuntimeError(error_msg) from last_error
async def list_tools(self) -> list[dict[str, Any]]:
"""List all tools available from the MCP server.

View file

@ -90,16 +90,22 @@ async def _create_mcp_tool_from_definition(
input_model = _create_dynamic_input_model_from_schema(tool_name, input_schema)
async def mcp_tool_call(**kwargs) -> str:
"""Execute the MCP tool call via the client."""
"""Execute the MCP tool call via the client with retry support."""
logger.info(f"MCP tool '{tool_name}' called with params: {kwargs}")
try:
# Connect to server and call tool
# Connect to server and call tool (connect has built-in retry logic)
async with mcp_client.connect():
result = await mcp_client.call_tool(tool_name, kwargs)
return str(result)
except RuntimeError as e:
# Connection failures after all retries
error_msg = f"MCP tool '{tool_name}' connection failed after retries: {e!s}"
logger.error(error_msg)
return f"Error: {error_msg}"
except Exception as e:
error_msg = f"MCP tool '{tool_name}' failed: {e!s}"
# Tool execution or other errors
error_msg = f"MCP tool '{tool_name}' execution failed: {e!s}"
logger.exception(error_msg)
return f"Error: {error_msg}"
@ -146,17 +152,38 @@ async def load_mcp_tools(
tools: list[StructuredTool] = []
for connector in result.scalars():
try:
# Extract server config
# Early validation: Extract and validate connector config
config = connector.config or {}
server_config = config.get("server_config", {})
command = server_config.get("command")
args = server_config.get("args", [])
env = server_config.get("env", {})
if not command:
# Validate server_config exists and is a dict
if not server_config or not isinstance(server_config, dict):
logger.warning(
f"MCP connector {connector.id} missing command, skipping"
f"MCP connector {connector.id} (name: '{connector.name}') has invalid or missing server_config, skipping"
)
continue
# Validate required command field
command = server_config.get("command")
if not command or not isinstance(command, str):
logger.warning(
f"MCP connector {connector.id} (name: '{connector.name}') missing or invalid command field, skipping"
)
continue
# Validate args field (must be list if present)
args = server_config.get("args", [])
if not isinstance(args, list):
logger.warning(
f"MCP connector {connector.id} (name: '{connector.name}') has invalid args field (must be list), skipping"
)
continue
# Validate env field (must be dict if present)
env = server_config.get("env", {})
if not isinstance(env, dict):
logger.warning(
f"MCP connector {connector.id} (name: '{connector.name}') has invalid env field (must be dict), skipping"
)
continue
@ -172,22 +199,21 @@ async def load_mcp_tools(
f"'{command}' (connector {connector.id})"
)
# Create LangChain tools from definitions
for tool_def in tool_definitions:
try:
tool = await _create_mcp_tool_from_definition(
tool_def, mcp_client
)
tools.append(tool)
except Exception as e:
logger.exception(
f"Failed to create tool '{tool_def.get('name')}' "
f"from connector {connector.id}: {e!s}",
)
# Create LangChain tools from definitions
for tool_def in tool_definitions:
try:
tool = await _create_mcp_tool_from_definition(
tool_def, mcp_client
)
tools.append(tool)
except Exception as e:
logger.exception(
f"Failed to create tool '{tool_def.get('name')}' "
f"from connector {connector.id}: {e!s}"
)
except Exception as e:
logger.exception(
f"Failed to load tools from MCP connector {connector.id}: {e!s}",
f"Failed to load tools from MCP connector {connector.id}: {e!s}"
)
logger.info(f"Loaded {len(tools)} MCP tools for search space {search_space_id}")

View file

@ -2108,7 +2108,7 @@ async def create_mcp_connector(
"You don't have permission to create connectors in this search space",
)
# Create the connector with server config
# Create the connector with single server config
db_connector = SearchSourceConnector(
name=connector_data.name,
connector_type=SearchSourceConnectorType.MCP_CONNECTOR,
@ -2125,7 +2125,7 @@ async def create_mcp_connector(
await session.refresh(db_connector)
logger.info(
f"Created MCP connector {db_connector.id} for server '{connector_data.server_config.command}' "
f"Created MCP connector {db_connector.id} "
f"for user {user.id} in search space {search_space_id}"
)

View file

@ -95,7 +95,7 @@ class MCPConnectorCreate(BaseModel):
"""Schema for creating an MCP connector."""
name: str
server_config: MCPServerConfig
server_config: MCPServerConfig # Single MCP server configuration
class MCPConnectorUpdate(BaseModel):
@ -106,7 +106,7 @@ class MCPConnectorUpdate(BaseModel):
class MCPConnectorRead(BaseModel):
"""Schema for reading an MCP connector with server config."""
"""Schema for reading an MCP connector with server configs."""
id: int
name: str
@ -123,7 +123,8 @@ class MCPConnectorRead(BaseModel):
def from_connector(cls, connector: SearchSourceConnectorRead) -> "MCPConnectorRead":
"""Convert from base SearchSourceConnectorRead."""
config = connector.config or {}
server_config = MCPServerConfig(**config.get("server_config", {}))
server_config_data = config.get("server_config", {})
server_config = MCPServerConfig(**server_config_data)
return cls(
id=connector.id,

View file

@ -22,6 +22,7 @@ import { useIndexingConnectors } from "./connector-popup/hooks/use-indexing-conn
import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab";
import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
import { MCPConnectorListView } from "./connector-popup/views/mcp-connector-list-view";
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
export const ConnectorIndicator: FC = () => {
@ -56,6 +57,7 @@ export const ConnectorIndicator: FC = () => {
frequencyMinutes,
allConnectors,
viewingAccountsType,
viewingMCPList,
setSearchQuery,
setStartDate,
setEndDate,
@ -79,6 +81,8 @@ export const ConnectorIndicator: FC = () => {
handleBackFromYouTube,
handleViewAccountsList,
handleBackFromAccountsList,
handleBackFromMCPList,
handleAddNewMCPFromList,
handleQuickIndexConnector,
connectorConfig,
setConnectorConfig,
@ -95,15 +99,15 @@ export const ConnectorIndicator: FC = () => {
refreshConnectors: refreshConnectorsElectric,
} = useConnectorsElectric(searchSpaceId);
// Fallback to API if Electric fails or is not available
const connectors =
connectorsFromElectric.length > 0 || !connectorsError
? connectorsFromElectric
: allConnectors || [];
// Fallback to API if Electric is not available or fails
// Use Electric data if: 1) we have data, or 2) still loading without error
// Use API data if: Electric failed (has error) or finished loading with no data
const useElectricData = connectorsFromElectric.length > 0 || (connectorsLoading && !connectorsError);
const connectors = useElectricData ? connectorsFromElectric : allConnectors || [];
// Manual refresh function that works with both Electric and API
const refreshConnectors = async () => {
if (connectorsFromElectric.length > 0 || !connectorsError) {
if (useElectricData) {
await refreshConnectorsElectric();
} else {
// Fallback: use allConnectors from useConnectorDialog (which uses connectorsAtom)
@ -126,7 +130,8 @@ export const ConnectorIndicator: FC = () => {
const hasConnectors = connectors.length > 0;
const hasSources = hasConnectors || activeDocumentTypes.length > 0;
const totalSourceCount = connectors.length + activeDocumentTypes.length;
const activeConnectorsCount = connectors.length; // Only actual connectors, not document types
const activeConnectorsCount = connectors.length;
// Check which connectors are already connected
// Using Electric SQL + PGlite for real-time connector updates
@ -171,6 +176,19 @@ export const ConnectorIndicator: FC = () => {
{/* YouTube Crawler View - shown when adding YouTube videos */}
{isYouTubeView && searchSpaceId ? (
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
) : viewingMCPList ? (
<div className="p-6 sm:p-12 h-full overflow-hidden">
<MCPConnectorListView
mcpConnectors={
(allConnectors || []).filter(
(c: SearchSourceConnector) => c.connector_type === "MCP_CONNECTOR"
) as SearchSourceConnector[]
}
onAddNew={handleAddNewMCPFromList}
onManageConnector={handleStartEdit}
onBack={handleBackFromMCPList}
/>
</div>
) : viewingAccountsType ? (
<ConnectorAccountsListView
connectorType={viewingAccountsType.connectorType}
@ -210,6 +228,8 @@ export const ConnectorIndicator: FC = () => {
isSaving={isSaving}
isDisconnecting={isDisconnecting}
isIndexing={indexingConnectorIds.has(editingConnector.id)}
searchSpaceId={searchSpaceId?.toString()}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}

View file

@ -0,0 +1,229 @@
"use client";
import { CheckCircle2, ChevronDown, ChevronUp, 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { EnumConnectorName } from "@/contracts/enums/connector";
import type { MCPToolDefinition } from "@/contracts/types/mcp.types";
import type { ConnectFormProps } from "..";
import {
extractServerName,
parseMCPConfig,
testMCPConnection,
type MCPConnectionTestResult,
} from "../../utils/mcp-config-validator";
export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const [configJson, setConfigJson] = useState("");
const [jsonError, setJsonError] = useState<string | null>(null);
const [isTesting, setIsTesting] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [testResult, setTestResult] = useState<MCPConnectionTestResult | null>(null);
const DEFAULT_CONFIG = JSON.stringify(
{
name: "My MCP Server",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/directory"],
env: {
API_KEY: "your_api_key_here",
},
transport: "stdio",
},
null,
2
);
const parseConfig = () => {
const result = parseMCPConfig(configJson);
if (result.error) {
setJsonError(result.error);
} else {
setJsonError(null);
}
return result.config;
};
const handleConfigChange = (value: string) => {
setConfigJson(value);
// Clear previous error
if (jsonError) {
setJsonError(null);
}
// Validate immediately to show errors as user types (with debouncing via parseMCPConfig cache)
if (value.trim()) {
const result = parseMCPConfig(value);
if (result.error) {
setJsonError(result.error);
}
}
};
const handleTestConnection = async () => {
const serverConfig = parseConfig();
if (!serverConfig) {
setTestResult({
status: "error",
message: jsonError || "Invalid configuration",
tools: [],
});
return;
}
setIsTesting(true);
setTestResult(null);
const result = await testMCPConnection(serverConfig);
setTestResult(result);
setIsTesting(false);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
const serverConfig = parseConfig();
if (!serverConfig) {
return;
}
// Extract server name from config if provided
const serverName = extractServerName(configJson);
isSubmittingRef.current = true;
try {
await onSubmit({
name: serverName,
connector_type: EnumConnectorName.MCP_CONNECTOR,
config: { server_config: serverConfig },
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 [&>svg]:top-2 sm:[&>svg]:top-3">
<Server className="h-4 w-4 shrink-0" />
<AlertDescription className="text-[10px] sm:text-xs">
Connect to an MCP (Model Context Protocol) server. Each MCP server is added as a separate connector.
</AlertDescription>
</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 Server Configuration (JSON)</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 a single MCP server configuration. Must include: 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 Connection..." : "Test Connection"}
</Button>
</div>
{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-600" />
) : (
<XCircle className="h-4 w-4 text-red-600" />
)}
<div className="flex-1">
<div className="flex items-center justify-between">
<AlertTitle className="text-sm">
{testResult.status === "success" ? "Connection Successful" : "Connection Failed"}
</AlertTitle>
{testResult.tools.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowDetails(!showDetails);
}}
>
{showDetails ? (
<>
<ChevronUp className="h-3 w-3 mr-1" />
Hide Details
</>
) : (
<>
<ChevronDown className="h-3 w-3 mr-1" />
Show Details
</>
)}
</Button>
)}
</div>
<AlertDescription className="text-xs mt-1">
{testResult.message}
{showDetails && testResult.tools.length > 0 && (
<div className="mt-3 pt-3 border-t border-green-500/20">
<p className="font-semibold mb-2">
Available tools:
</p>
<ul className="list-disc list-inside text-xs space-y-0.5">
{testResult.tools.map((tool, i) => (
<li key={i}>{tool.name}</li>
))}
</ul>
</div>
)}
</AlertDescription>
</div>
</Alert>
)}
</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,245 @@
"use client";
import { CheckCircle2, ChevronDown, ChevronUp, 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 { EnumConnectorName } from "@/contracts/enums/connector";
import type { MCPServerConfig, MCPToolDefinition } from "@/contracts/types/mcp.types";
import type { ConnectorConfigProps } from "../index";
import {
parseMCPConfig,
testMCPConnection,
type MCPConnectionTestResult,
} from "../../utils/mcp-config-validator";
interface MCPConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void;
}
export const MCPConfig: FC<MCPConfigProps> = ({ connector, onConfigChange, onNameChange }) => {
// Validate that this is an MCP connector
if (connector.connector_type !== EnumConnectorName.MCP_CONNECTOR) {
console.error(
"MCPConfig received non-MCP connector:",
connector.connector_type
);
return (
<Alert className="border-red-500/50 bg-red-500/10">
<XCircle className="h-4 w-4 text-red-600" />
<AlertTitle>Invalid Connector Type</AlertTitle>
<AlertDescription>
This component can only be used with MCP connectors.
</AlertDescription>
</Alert>
);
}
const [name, setName] = useState<string>("");
const [configJson, setConfigJson] = useState("");
const [jsonError, setJsonError] = useState<string | null>(null);
const [isTesting, setIsTesting] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [testResult, setTestResult] = useState<MCPConnectionTestResult | null>(null);
// Initialize form from connector config (only on mount)
useEffect(() => {
if (connector.name) {
setName(connector.name);
}
const serverConfig = connector.config?.server_config as MCPServerConfig | undefined;
if (serverConfig) {
// Convert server config to JSON string for editing (name is in separate field)
const configObj = {
command: serverConfig.command || "",
args: serverConfig.args || [],
env: serverConfig.env || {},
transport: serverConfig.transport || "stdio",
};
setConfigJson(JSON.stringify(configObj, null, 2));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Only run on mount to preserve user edits
const handleNameChange = (value: string) => {
setName(value);
if (onNameChange) {
onNameChange(value);
}
};
const parseConfig = () => {
const result = parseMCPConfig(configJson);
if (result.error) {
setJsonError(result.error);
} else {
setJsonError(null);
}
return result.config;
};
const handleConfigChange = (value: string) => {
setConfigJson(value);
if (jsonError) {
setJsonError(null);
}
// Use shared utility for validation and parsing (with caching)
const result = parseMCPConfig(value);
if (result.config && onConfigChange) {
// Valid config - update parent immediately
onConfigChange({ server_config: result.config });
}
// Ignore errors while typing - only show errors when user tests or saves
};
const handleTestConnection = async () => {
const serverConfig = parseConfig();
if (!serverConfig) {
setTestResult({
status: "error",
message: jsonError || "Invalid configuration",
tools: [],
});
return;
}
// Update parent with the config
if (onConfigChange) {
onConfigChange({ server_config: serverConfig });
}
setIsTesting(true);
setTestResult(null);
const result = await testMCPConnection(serverConfig);
setTestResult(result);
setIsTesting(false);
};
return (
<div className="space-y-6">
{/* Server Name */}
<div className="space-y-2">
<Label htmlFor="name">Server Name *</Label>
<Input
id="name"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="e.g., Filesystem Server"
required
/>
</div>
{/* 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">
<Label htmlFor="config">MCP Server Configuration (JSON)</Label>
<Textarea
id="config"
value={configJson}
onChange={(e) => handleConfigChange(e.target.value)}
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">
Edit your MCP server configuration. Must include: name, command, args (optional), env (optional), transport (optional).
</p>
</div>
{/* Test Connection */}
<div className="pt-4">
<Button
type="button"
onClick={handleTestConnection}
disabled={isTesting}
variant="outline"
className="w-full"
>
{isTesting ? "Testing Connection..." : "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-600" />
) : (
<XCircle className="h-4 w-4 text-red-600" />
)}
<div className="flex-1">
<div className="flex items-center justify-between">
<AlertTitle className="text-sm">
{testResult.status === "success" ? "Connection Successful" : "Connection Failed"}
</AlertTitle>
{testResult.tools.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowDetails(!showDetails);
}}
>
{showDetails ? (
<>
<ChevronUp className="h-3 w-3 mr-1" />
Hide Details
</>
) : (
<>
<ChevronDown className="h-3 w-3 mr-1" />
Show Details
</>
)}
</Button>
)}
</div>
<AlertDescription className="text-xs mt-1">
{testResult.message}
{showDetails && testResult.tools.length > 0 && (
<div className="mt-3 pt-3 border-t border-green-500/20">
<p className="font-semibold mb-2">
Available tools:
</p>
<ul className="list-disc list-inside text-xs space-y-0.5">
{testResult.tools.map((tool, i) => (
<li key={i}>{tool.name}</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,7 @@ export interface ConnectorConfigProps {
connector: SearchSourceConnector;
onConfigChange?: (config: Record<string, unknown>) => void;
onNameChange?: (name: string) => void;
searchSpaceId?: string;
}
export type ConnectorConfigComponent = FC<ConnectorConfigProps>;
@ -69,6 +71,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) {
@ -98,7 +99,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
</div>
<div>
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">
Connect {getConnectorTypeDisplay(connectorType)}
Connect {connectorType === "MCP_CONNECTOR" ? "MCP Server" : getConnectorTypeDisplay(connectorType)}
</h2>
<p className="text-xs sm:text-base text-muted-foreground mt-1">
Enter your connection details
@ -138,7 +139,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
Connecting
</>
) : (
<>Connect {getConnectorTypeDisplay(connectorType)}</>
<>{connectorType === "MCP_CONNECTOR" ? "Connect" : `Connect ${getConnectorTypeDisplay(connectorType)}`}</>
)}
</Button>
</div>

View file

@ -19,6 +19,7 @@ interface ConnectorEditViewProps {
isSaving: boolean;
isDisconnecting: boolean;
isIndexing?: boolean;
searchSpaceId?: string;
onStartDateChange: (date: Date | undefined) => void;
onEndDateChange: (date: Date | undefined) => void;
onPeriodicEnabledChange: (enabled: boolean) => void;
@ -40,6 +41,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
isSaving,
isDisconnecting,
isIndexing = false,
searchSpaceId,
onStartDateChange,
onEndDateChange,
onPeriodicEnabledChange,
@ -149,7 +151,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" ? "MCP Server" : connector.name}
</h2>
<p className="text-xs sm:text-base text-muted-foreground mt-1">
Manage your connector settings and sync configuration
@ -197,6 +199,8 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
connector={connector}
onConfigChange={onConfigChange}
onNameChange={onNameChange}
searchSpaceId={searchSpaceId}
/>
)}

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

@ -7,7 +7,7 @@ import { searchSourceConnectorTypeEnum } from "@/contracts/types/connector.types
export const connectorPopupQueryParamsSchema = z.object({
modal: z.enum(["connectors"]).optional(),
tab: z.enum(["all", "active"]).optional(),
view: z.enum(["configure", "edit", "connect", "youtube", "accounts"]).optional(),
view: z.enum(["configure", "edit", "connect", "youtube", "accounts", "mcp-list"]).optional(),
connector: z.string().optional(),
connectorId: z.string().optional(),
connectorType: z.string().optional(),

View file

@ -80,12 +80,18 @@ export const useConnectorDialog = () => {
connectorTitle: string;
} | null>(null);
// MCP list view state (for managing multiple MCP connectors)
const [viewingMCPList, setViewingMCPList] = useState(false);
// Track if we came from accounts list when entering edit mode
const [cameFromAccountsList, setCameFromAccountsList] = useState<{
connectorType: string;
connectorTitle: string;
} | null>(null);
// Track if we came from MCP list view when entering edit mode
const [cameFromMCPList, setCameFromMCPList] = useState(false);
// Helper function to get frequency label
const getFrequencyLabel = useCallback((minutes: string): string => {
switch (minutes) {
@ -139,6 +145,16 @@ export const useConnectorDialog = () => {
setViewingAccountsType(null);
}
// Clear MCP list view if view is not "mcp-list" anymore
if (params.view !== "mcp-list" && viewingMCPList) {
setViewingMCPList(false);
}
// Handle MCP list view
if (params.view === "mcp-list" && !viewingMCPList) {
setViewingMCPList(true);
}
// Handle connect view
if (params.view === "connect" && params.connectorType && !connectingConnectorType) {
setConnectingConnectorType(params.connectorType);
@ -421,6 +437,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,
@ -525,17 +542,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) {
@ -628,32 +646,34 @@ export const useConnectorDialog = () => {
},
});
toast.success(`${connectorTitle} connected and indexing started!`, {
description: periodicEnabledForIndexing
? `Periodic sync enabled every ${getFrequencyLabel(frequencyMinutesForIndexing)}.`
: "You can continue working while we sync your data.",
});
const successMessage = currentConnectorType === "MCP_CONNECTOR"
? `${connector.name} MCP server added successfully`
: `${connectorTitle} connected and indexing started!`;
toast.success(successMessage, {
description: periodicEnabledForIndexing
? `Periodic sync enabled every ${getFrequencyLabel(frequencyMinutesForIndexing)}.`
: "You can continue working while we sync your data.",
});
// Close modal and return to main view
const url = new URL(window.location.href);
url.searchParams.delete("modal");
url.searchParams.delete("tab");
url.searchParams.delete("view");
url.searchParams.delete("connectorType");
router.replace(url.pathname + url.search, { scroll: false });
const url = new URL(window.location.href);
url.searchParams.delete("modal");
url.searchParams.delete("tab");
url.searchParams.delete("view");
url.searchParams.delete("connectorType");
router.replace(url.pathname + url.search, { scroll: false });
// Clear indexing config state since we're not showing the view
setIndexingConfig(null);
setIndexingConnector(null);
setIndexingConnectorConfig(null);
// Clear indexing config state since we're not showing the view
setIndexingConfig(null);
setIndexingConnector(null);
setIndexingConnectorConfig(null);
// Invalidate queries to refresh data
queryClient.invalidateQueries({
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
});
// Invalidate queries to refresh data
queryClient.invalidateQueries({
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
});
// Refresh connectors list
await refetchAllConnectors();
// Refresh connectors list
await refetchAllConnectors();
} else {
// Non-indexable connector
// For Circleback, transition to edit view to show webhook URL
@ -690,7 +710,13 @@ export const useConnectorDialog = () => {
await refetchAllConnectors();
} else {
// Other non-indexable connectors - just show success message and close
toast.success(`${connectorTitle} connected successfully!`);
const successMessage = currentConnectorType === "MCP_CONNECTOR"
? `${connector.name} MCP server added successfully`
: `${connectorTitle} connected successfully!`;
toast.success(successMessage);
// Refresh connectors list before closing modal
await refetchAllConnectors();
// Close modal and return to main view
const url = new URL(window.location.href);
@ -734,11 +760,18 @@ export const useConnectorDialog = () => {
const handleBackFromConnect = useCallback(() => {
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("tab", "all");
url.searchParams.delete("view");
// If we're connecting an MCP and came from list view, go back to list
if (connectingConnectorType === "MCP_CONNECTOR" && viewingMCPList) {
url.searchParams.set("view", "mcp-list");
} else {
url.searchParams.set("tab", "all");
url.searchParams.delete("view");
}
url.searchParams.delete("connectorType");
router.replace(url.pathname + url.search, { scroll: false });
}, [router]);
}, [router, connectingConnectorType, viewingMCPList]);
// Handle going back from YouTube view
const handleBackFromYouTube = useCallback(() => {
@ -781,6 +814,38 @@ export const useConnectorDialog = () => {
router.replace(url.pathname + url.search, { scroll: false });
}, [router]);
// Handle viewing MCP list
const handleViewMCPList = useCallback(() => {
if (!searchSpaceId) return;
setViewingMCPList(true);
// Update URL to show MCP list view
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "mcp-list");
window.history.pushState({ modal: true }, "", url.toString());
}, [searchSpaceId]);
// Handle going back from MCP list view
const handleBackFromMCPList = useCallback(() => {
setViewingMCPList(false);
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.delete("view");
router.replace(url.pathname + url.search, { scroll: false });
}, [router]);
// Handle adding new MCP from list view
const handleAddNewMCPFromList = useCallback(() => {
setConnectingConnectorType("MCP_CONNECTOR");
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "connect");
url.searchParams.set("connectorType", "MCP_CONNECTOR");
router.replace(url.pathname + url.search, { scroll: false });
}, [router]);
// Handle starting indexing
const handleStartIndexing = useCallback(
async (refreshConnectors: () => void) => {
@ -966,6 +1031,13 @@ export const useConnectorDialog = () => {
(connector: SearchSourceConnector) => {
if (!searchSpaceId) return;
// For MCP connectors from "All Connectors" tab, show the list view instead of directly editing
// (unless we're already in the MCP list view or on the Active tab where individual MCPs are shown)
if (connector.connector_type === "MCP_CONNECTOR" && !viewingMCPList && activeTab === "all") {
handleViewMCPList();
return;
}
// All connector types should be handled in the popup edit view
// Validate connector data
const connectorValidation = searchSourceConnector.safeParse(connector);
@ -982,6 +1054,13 @@ export const useConnectorDialog = () => {
setCameFromAccountsList(null);
}
// Track if we came from MCP list view
if (viewingMCPList && connector.connector_type === "MCP_CONNECTOR") {
setCameFromMCPList(true);
} else {
setCameFromMCPList(false);
}
// Track index with date range opened event
if (connector.is_indexable) {
trackIndexWithDateRangeOpened(
@ -1011,13 +1090,13 @@ export const useConnectorDialog = () => {
url.searchParams.set("connectorId", connector.id.toString());
window.history.pushState({ modal: true }, "", url.toString());
},
[searchSpaceId, viewingAccountsType]
[searchSpaceId, viewingAccountsType, viewingMCPList, handleViewMCPList, activeTab]
);
// 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 (
@ -1156,34 +1235,38 @@ export const useConnectorDialog = () => {
);
}
toast.success(`${editingConnector.name} updated successfully`, {
description: periodicEnabled
? `Periodic sync ${frequency ? `enabled every ${getFrequencyLabel(frequencyMinutes)}` : "enabled"}. ${indexingDescription}`
: indexingDescription,
});
// Generate toast message based on connector type
const toastTitle = `${editingConnector.name} updated successfully`;
// Update URL - the effect will handle closing the modal and clearing state
const url = new URL(window.location.href);
url.searchParams.delete("modal");
url.searchParams.delete("tab");
url.searchParams.delete("view");
url.searchParams.delete("connectorId");
router.replace(url.pathname + url.search, { scroll: false });
toast.success(toastTitle, {
description: periodicEnabled
? `Periodic sync ${frequency ? `enabled every ${getFrequencyLabel(frequencyMinutes)}` : "enabled"}. ${indexingDescription}`
: indexingDescription,
});
refreshConnectors();
queryClient.invalidateQueries({
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
});
} catch (error) {
console.error("Error saving connector:", error);
toast.error("Failed to save connector changes");
} finally {
setIsSaving(false);
}
// Update URL - the effect will handle closing the modal and clearing state
const url = new URL(window.location.href);
url.searchParams.delete("modal");
url.searchParams.delete("tab");
url.searchParams.delete("view");
url.searchParams.delete("connectorId");
router.replace(url.pathname + url.search, { scroll: false });
refreshConnectors();
queryClient.invalidateQueries({
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
});
} catch (error) {
console.error("Error saving connector:", error);
toast.error("Failed to save connector changes");
} finally {
setIsSaving(false);
}
},
[
editingConnector,
searchSpaceId,
isSaving,
startDate,
endDate,
indexConnector,
@ -1215,14 +1298,27 @@ export const useConnectorDialog = () => {
editingConnector.id
);
toast.success(`${editingConnector.name} disconnected successfully`);
toast.success(
editingConnector.connector_type === "MCP_CONNECTOR"
? `${editingConnector.name} MCP server removed successfully`
: `${editingConnector.name} disconnected successfully`
);
// Update URL - the effect will handle closing the modal and clearing state
// Update URL - for MCP from list view, go back to list; otherwise close modal
const url = new URL(window.location.href);
url.searchParams.delete("modal");
url.searchParams.delete("tab");
url.searchParams.delete("view");
url.searchParams.delete("connectorId");
if (editingConnector.connector_type === "MCP_CONNECTOR" && cameFromMCPList) {
// Go back to MCP list view only if we came from there
setViewingMCPList(true);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "mcp-list");
url.searchParams.delete("connectorId");
} else {
// Close modal for all other cases
url.searchParams.delete("modal");
url.searchParams.delete("tab");
url.searchParams.delete("view");
url.searchParams.delete("connectorId");
}
router.replace(url.pathname + url.search, { scroll: false });
refreshConnectors();
@ -1274,6 +1370,21 @@ export const useConnectorDialog = () => {
// Handle going back from edit view
const handleBackFromEdit = useCallback(() => {
// If editing an MCP connector and came from MCP list, go back to MCP list view
if (editingConnector?.connector_type === "MCP_CONNECTOR" && cameFromMCPList) {
setViewingMCPList(true);
setCameFromMCPList(false);
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "mcp-list");
url.searchParams.delete("connectorId");
router.replace(url.pathname + url.search, { scroll: false });
setEditingConnector(null);
setConnectorName(null);
setConnectorConfig(null);
return;
}
// If we came from accounts list view, go back there
if (cameFromAccountsList && editingConnector) {
// Restore accounts list view
@ -1286,10 +1397,10 @@ export const useConnectorDialog = () => {
url.searchParams.delete("connectorId");
router.replace(url.pathname + url.search, { scroll: false });
} else {
// Otherwise, go back to main connector popup
// Otherwise, go back to main connector popup (preserve the tab the user was on)
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("tab", "all");
url.searchParams.set("tab", activeTab); // Use current tab instead of always "all"
url.searchParams.delete("view");
url.searchParams.delete("connectorId");
router.replace(url.pathname + url.search, { scroll: false });
@ -1297,7 +1408,7 @@ export const useConnectorDialog = () => {
setEditingConnector(null);
setConnectorName(null);
setConnectorConfig(null);
}, [router, cameFromAccountsList, editingConnector]);
}, [router, cameFromAccountsList, editingConnector, cameFromMCPList, activeTab]);
// Handle dialog open/close
const handleOpenChange = useCallback(
@ -1375,6 +1486,7 @@ export const useConnectorDialog = () => {
searchSpaceId,
allConnectors,
viewingAccountsType,
viewingMCPList,
// Setters
setSearchQuery,
@ -1403,6 +1515,9 @@ export const useConnectorDialog = () => {
handleBackFromYouTube,
handleViewAccountsList,
handleBackFromAccountsList,
handleViewMCPList,
handleBackFromMCPList,
handleAddNewMCPFromList,
handleQuickIndexConnector,
connectorConfig,
setConnectorConfig,

View file

@ -3,11 +3,15 @@
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";
@ -25,6 +29,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,
@ -84,7 +96,9 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
// Separate OAuth and non-OAuth connectors
const oauthConnectors = connectors.filter((c) => oauthConnectorTypes.has(c.connector_type));
const nonOauthConnectors = connectors.filter((c) => !oauthConnectorTypes.has(c.connector_type));
const nonOauthConnectors = connectors.filter(
(c) => !oauthConnectorTypes.has(c.connector_type)
);
// Group OAuth connectors by type
const oauthConnectorsByType = oauthConnectors.reduce(
@ -136,7 +150,8 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
});
const hasActiveConnectors =
filteredOAuthConnectorTypes.length > 0 || filteredNonOAuthConnectors.length > 0;
filteredOAuthConnectorTypes.length > 0 ||
filteredNonOAuthConnectors.length > 0;
return (
<TabsContent value="active" className="m-0">
@ -225,7 +240,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
connector.connector_type,
documentTypeCounts
);
const isMCPConnector = connector.connector_type === "MCP_CONNECTOR";
return (
<div
key={`connector-${connector.id}`}
@ -247,19 +262,21 @@ 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" />
Syncing
</p>
) : (
) : !isMCPConnector ? (
<p className="text-[10px] text-muted-foreground mt-1">
{formatDocumentCount(documentCount)}
</p>
)}
) : null}
</div>
<Button
variant="secondary"

View file

@ -89,6 +89,20 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
)
: [];
const accountCount = typeConnectors.length;
// Get the most recent last_indexed_at across all accounts
const mostRecentLastIndexed = typeConnectors.reduce<string | undefined>(
(latest, c) => {
if (!c.last_indexed_at) return latest;
if (!latest) return c.last_indexed_at;
return new Date(c.last_indexed_at) > new Date(latest)
? c.last_indexed_at
: latest;
},
undefined
);
const documentCount = getDocumentCountForConnector(
connector.connectorType,
documentTypeCounts
@ -107,7 +121,8 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
isConnected={isConnected}
isConnecting={isConnecting}
documentCount={documentCount}
accountCount={typeConnectors.length}
accountCount={accountCount}
isIndexing={isIndexing}
onConnect={() => onConnectOAuth(connector)}
onManage={
@ -161,6 +176,7 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
isConnected={isConnected}
isConnecting={isConnecting}
documentCount={documentCount}
isIndexing={isIndexing}
onConnect={handleConnect}
onManage={

View file

@ -0,0 +1,254 @@
/**
* MCP Configuration Validator Utility
*
* Shared validation and parsing logic for MCP (Model Context Protocol) server configurations.
*
* Features:
* - Zod schema validation for runtime type safety
* - Configuration caching to avoid repeated parsing (5-minute TTL)
* - Standardized error messages
* - Connection testing utilities
*
* Usage:
* ```typescript
* // Parse and validate config
* const result = parseMCPConfig(jsonString);
* if (result.config) {
* // Valid config
* } else {
* // Show result.error to user
* }
*
* // Test connection
* const testResult = await testMCPConnection(config);
* if (testResult.status === "success") {
* console.log(`Found ${testResult.tools.length} tools`);
* }
* ```
*
* @module mcp-config-validator
*/
import { z } from "zod";
import type { MCPServerConfig, MCPToolDefinition } from "@/contracts/types/mcp.types";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
/**
* Zod schema for MCP server configuration
* Provides compile-time and runtime type safety
*
* Exported for advanced use cases (e.g., form builders)
*/
export const MCPServerConfigSchema = z.object({
name: z.string().optional(),
command: z
.string({ required_error: "Command field is required" })
.min(1, "Command cannot be empty"),
args: z.array(z.string()).optional().default([]),
env: z.record(z.string(), z.string()).optional().default({}),
transport: z.enum(["stdio", "sse"]).optional().default("stdio"),
});
/**
* Shared MCP configuration validation result
*/
export interface MCPConfigValidationResult {
config: MCPServerConfig | null;
error: string | null;
}
/**
* Shared MCP connection test result
*/
export interface MCPConnectionTestResult {
status: "success" | "error";
message: string;
tools: MCPToolDefinition[];
}
/**
* Cache for parsed configurations to avoid re-parsing
* Key: JSON string, Value: { config, timestamp }
*/
const configCache = new Map<string, { config: MCPServerConfig; timestamp: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
/**
* Clear expired entries from config cache
*/
const clearExpiredCache = () => {
const now = Date.now();
for (const [key, value] of configCache.entries()) {
if (now - value.timestamp > CACHE_TTL) {
configCache.delete(key);
}
}
};
/**
* Parse and validate MCP server configuration from JSON string
* Uses Zod for schema validation and caching to avoid re-parsing
* @param configJson - JSON string containing MCP server configuration
* @returns Validation result with parsed config or error message
*/
export const parseMCPConfig = (configJson: string): MCPConfigValidationResult => {
// Check cache first
const cached = configCache.get(configJson);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
console.log('[MCP Validator] ✅ Using cached config');
return { config: cached.config, error: null };
}
console.log('[MCP Validator] 🔍 Parsing new config...');
// Clean up expired cache entries periodically
if (configCache.size > 100) {
clearExpiredCache();
}
try {
const parsed = JSON.parse(configJson);
// Validate that it's an object, not an array
if (Array.isArray(parsed)) {
console.error('[MCP Validator] ❌ Error: Config is an array, expected object');
return {
config: null,
error: "Please provide a single server configuration object, not an array",
};
}
// Use Zod schema validation for robust type checking
const result = MCPServerConfigSchema.safeParse(parsed);
if (!result.success) {
// Format Zod validation errors for user-friendly display
const firstError = result.error.issues[0];
const fieldPath = firstError.path.join(".");
// Clean up error message - remove technical Zod jargon
let errorMsg = firstError.message;
// Replace technical error messages with user-friendly ones
if (errorMsg.includes("expected string, received undefined")) {
errorMsg = "This field is required";
} else if (errorMsg.includes("Invalid input")) {
errorMsg = "Invalid value";
}
const formattedError = fieldPath ? `${fieldPath}: ${errorMsg}` : errorMsg;
console.error('[MCP Validator] ❌ Validation error:', formattedError);
console.error('[MCP Validator] Full Zod errors:', result.error.issues);
return {
config: null,
error: formattedError,
};
}
const config: MCPServerConfig = {
command: result.data.command,
args: result.data.args,
env: result.data.env,
transport: result.data.transport,
};
// Cache the successfully parsed config
configCache.set(configJson, {
config,
timestamp: Date.now(),
});
console.log('[MCP Validator] ✅ Config parsed successfully:', config);
return {
config,
error: null,
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : "Invalid JSON";
console.error('[MCP Validator] ❌ JSON parse error:', errorMsg);
return {
config: null,
error: errorMsg,
};
}
};
/**
* Test connection to MCP server
* @param serverConfig - MCP server configuration to test
* @returns Connection test result with status, message, and available tools
*/
export const testMCPConnection = async (
serverConfig: MCPServerConfig
): Promise<MCPConnectionTestResult> => {
try {
const result = await connectorsApiService.testMCPConnection(serverConfig);
if (result.status === "success") {
return {
status: "success",
message: `Successfully connected. Found ${result.tools.length} tool${result.tools.length !== 1 ? "s" : ""}.`,
tools: result.tools,
};
}
return {
status: "error",
message: result.message || "Failed to connect",
tools: [],
};
} catch (error) {
return {
status: "error",
message: error instanceof Error ? error.message : "Failed to connect",
tools: [],
};
}
};
/**
* Extract server name from MCP config JSON with caching
* @param configJson - JSON string containing MCP server configuration
* @returns Server name if found, otherwise default name
*/
export const extractServerName = (configJson: string): string => {
try {
const parsed = JSON.parse(configJson);
// Use Zod to validate and extract name field safely
const nameSchema = z.object({ name: z.string().optional() });
const result = nameSchema.safeParse(parsed);
if (result.success && result.data.name) {
return result.data.name;
}
} catch {
// Return default if parsing fails
}
return "MCP Server";
};
/**
* Clear the configuration cache
* Useful for testing or when memory management is needed
*/
export const clearConfigCache = () => {
configCache.clear();
};
/**
* Get cache statistics for monitoring/debugging
*/
export const getConfigCacheStats = () => {
return {
size: configCache.size,
entries: Array.from(configCache.entries()).map(([key, value]) => ({
configPreview: key.substring(0, 50) + (key.length > 50 ? "..." : ""),
timestamp: new Date(value.timestamp).toISOString(),
age: Date.now() - value.timestamp,
})),
};
};

View file

@ -21,6 +21,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
*/
@ -166,9 +174,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>

View file

@ -0,0 +1,145 @@
"use client";
import { Plus, Server, XCircle } from "lucide-react";
import type { FC } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { cn } from "@/lib/utils";
interface MCPConnectorListViewProps {
mcpConnectors: SearchSourceConnector[];
onAddNew: () => void;
onManageConnector: (connector: SearchSourceConnector) => void;
onBack: () => void;
}
export const MCPConnectorListView: FC<MCPConnectorListViewProps> = ({
mcpConnectors,
onAddNew,
onManageConnector,
onBack,
}) => {
// Validate that all connectors are MCP connectors
const invalidConnectors = mcpConnectors.filter(
(c) => c.connector_type !== EnumConnectorName.MCP_CONNECTOR
);
if (invalidConnectors.length > 0) {
console.error(
"MCPConnectorListView received non-MCP connectors:",
invalidConnectors.map((c) => c.connector_type)
);
return (
<Alert className="border-red-500/50 bg-red-500/10">
<XCircle className="h-4 w-4 text-red-600" />
<AlertTitle>Invalid Connector Type</AlertTitle>
<AlertDescription>
This view can only display MCP connectors. Found {invalidConnectors.length} invalid
connector(s).
</AlertDescription>
</Alert>
);
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between mb-6 shrink-0">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="h-8 w-8"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m15 18-6-6 6-6" />
</svg>
</Button>
<div>
<h2 className="text-lg sm:text-xl font-semibold">MCP Connectors</h2>
<p className="text-xs sm:text-sm text-muted-foreground">
Manage your Model Context Protocol servers
</p>
</div>
</div>
</div>
{/* Add New Button */}
<div className="mb-4 shrink-0">
<Button
onClick={onAddNew}
className="w-full"
variant="outline"
>
<Plus className="h-4 w-4 mr-2" />
Add New MCP Server
</Button>
</div>
{/* MCP Connectors List */}
<div className="space-y-3 flex-1 overflow-y-auto">
{mcpConnectors.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="h-16 w-16 rounded-full bg-slate-400/5 dark:bg-white/5 flex items-center justify-center mb-4">
<Server className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-sm font-medium mb-1">No MCP Servers</h3>
<p className="text-xs text-muted-foreground max-w-[280px]">
Get started by adding your first Model Context Protocol server
</p>
</div>
) : (
mcpConnectors.map((connector) => {
// Extract server name from config
const serverName = connector.config?.server_config?.name || connector.name;
return (
<div
key={connector.id}
className={cn(
"flex items-center gap-4 p-4 rounded-xl border border-border transition-all",
"bg-slate-400/5 dark:bg-white/5 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">
{serverName}
</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={() => onManageConnector(connector)}
>
Manage
</Button>
</div>
);
})
)}
</div>
</div>
);
};

View file

@ -23,4 +23,5 @@ export enum EnumConnectorName {
WEBCRAWLER_CONNECTOR = "WEBCRAWLER_CONNECTOR",
YOUTUBE_CONNECTOR = "YOUTUBE_CONNECTOR",
CIRCLEBACK_CONNECTOR = "CIRCLEBACK_CONNECTOR",
MCP_CONNECTOR = "MCP_CONNECTOR",
}

View file

@ -64,6 +64,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
return <Image src="/connectors/youtube.svg" alt="YouTube" {...imgProps} />;
case EnumConnectorName.CIRCLEBACK_CONNECTOR:
return <IconUsersGroup {...iconProps} />;
case EnumConnectorName.MCP_CONNECTOR:
return <Webhook {...iconProps} />;
// Additional cases for non-enum connector types
case "YOUTUBE_CONNECTOR":
return <Image src="/connectors/youtube.svg" alt="YouTube" {...imgProps} />;

View file

@ -26,6 +26,7 @@ export const searchSourceConnectorTypeEnum = z.enum([
"YOUTUBE_CONNECTOR",
"BOOKSTACK_CONNECTOR",
"CIRCLEBACK_CONNECTOR",
"MCP_CONNECTOR",
]);
export const searchSourceConnector = z.object({
@ -33,6 +34,7 @@ export const searchSourceConnector = z.object({
name: z.string(),
connector_type: searchSourceConnectorTypeEnum,
is_indexable: z.boolean(),
is_active: z.boolean().default(true),
last_indexed_at: z.string().nullable(),
config: z.record(z.string(), z.any()),
periodic_indexing_enabled: z.boolean(),
@ -85,6 +87,7 @@ export const createConnectorRequest = z.object({
name: true,
connector_type: true,
is_indexable: true,
is_active: true,
last_indexed_at: true,
config: true,
periodic_indexing_enabled: true,
@ -108,6 +111,7 @@ export const updateConnectorRequest = z.object({
name: true,
connector_type: true,
is_indexable: true,
is_active: true,
last_indexed_at: true,
config: true,
periodic_indexing_enabled: true,

View file

@ -0,0 +1,83 @@
import { z } from "zod";
/**
* MCP Server Configuration Schema (similar to Cursor's config)
*/
export const mcpServerConfig = z.object({
command: z.string().min(1, "Command is required"),
args: z.array(z.string()).default([]),
env: z.record(z.string(), z.string()).default({}),
transport: z.enum(["stdio", "sse", "http"]).default("stdio"),
});
/**
* MCP Connector Schemas
*/
export const mcpConnectorCreate = z.object({
name: z.string().min(1, "Connector name is required"),
server_config: mcpServerConfig,
});
export const mcpConnectorUpdate = z.object({
name: z.string().min(1).optional(),
server_config: mcpServerConfig.optional(),
});
export const mcpConnectorRead = z.object({
id: z.number(),
name: z.string(),
connector_type: z.literal("MCP_CONNECTOR"),
server_config: mcpServerConfig,
search_space_id: z.number(),
user_id: z.string(),
created_at: z.string(),
updated_at: z.string(),
});
/**
* API Request/Response Types
*/
export const createMCPConnectorRequest = z.object({
data: mcpConnectorCreate,
queryParams: z.object({
search_space_id: z.number().or(z.string()),
}),
});
export const updateMCPConnectorRequest = z.object({
id: z.number(),
data: mcpConnectorUpdate,
});
export const getMCPConnectorsRequest = z.object({
queryParams: z.object({
search_space_id: z.number().or(z.string()),
}),
});
// Inferred Types
export type MCPServerConfig = z.infer<typeof mcpServerConfig>;
export type MCPConnectorCreate = z.infer<typeof mcpConnectorCreate>;
export type MCPConnectorUpdate = z.infer<typeof mcpConnectorUpdate>;
export type MCPConnectorRead = z.infer<typeof mcpConnectorRead>;
export type CreateMCPConnectorRequest = z.infer<typeof createMCPConnectorRequest>;
export type UpdateMCPConnectorRequest = z.infer<typeof updateMCPConnectorRequest>;
export type GetMCPConnectorsRequest = z.infer<typeof getMCPConnectorsRequest>;
/**
* Tool definition from MCP server
*/
export type MCPToolDefinition = {
name: string;
description: string;
input_schema: Record<string, any>;
};
/**
* Test connection response
*/
export type MCPTestConnectionResponse = {
status: "success" | "error";
message: string;
tools: MCPToolDefinition[];
};

View file

@ -46,12 +46,17 @@ export function useConnectorsElectric(searchSpaceId: number | string | null) {
// Start syncing when Electric client is available
useEffect(() => {
// Wait for both searchSpaceId and Electric client to be available
if (!searchSpaceId || !electricClient) {
setLoading(!electricClient); // Still loading if waiting for Electric
if (!searchSpaceId) {
setConnectors([]);
}
// If no Electric client available, immediately mark as not loading (disabled)
if (!electricClient) {
setLoading(false);
setError(new Error("Electric SQL not configured"));
return;
}
// Wait for searchSpaceId to be available
if (!searchSpaceId) {
setConnectors([]);
setLoading(false);
return;
}

View file

@ -24,6 +24,14 @@ import {
updateConnectorRequest,
updateConnectorResponse,
} from "@/contracts/types/connector.types";
import type {
CreateMCPConnectorRequest,
GetMCPConnectorsRequest,
MCPConnectorRead,
MCPServerConfig,
MCPTestConnectionResponse,
UpdateMCPConnectorRequest,
} from "@/contracts/types/mcp.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
@ -224,6 +232,76 @@ class ConnectorsApiService {
listGoogleDriveFoldersResponse
);
};
// =============================================================================
// MCP Connector Methods
// =============================================================================
/**
* Get all MCP connectors for a search space
*/
getMCPConnectors = async (request: GetMCPConnectorsRequest) => {
const { search_space_id } = request.queryParams;
const queryString = new URLSearchParams({
search_space_id: String(search_space_id),
}).toString();
return baseApiService.get<MCPConnectorRead[]>(`/api/v1/connectors/mcp?${queryString}`);
};
/**
* Get a single MCP connector by ID
*/
getMCPConnector = async (connectorId: number) => {
return baseApiService.get<MCPConnectorRead>(`/api/v1/connectors/mcp/${connectorId}`);
};
/**
* Create a new MCP connector
*/
createMCPConnector = async (request: CreateMCPConnectorRequest) => {
const { data, queryParams } = request;
const queryString = new URLSearchParams({
search_space_id: String(queryParams.search_space_id),
}).toString();
return baseApiService.post<MCPConnectorRead>(`/api/v1/connectors/mcp?${queryString}`, undefined, {
body: data,
});
};
/**
* Update an existing MCP connector
*/
updateMCPConnector = async (request: UpdateMCPConnectorRequest) => {
const { id, data } = request;
return baseApiService.put<MCPConnectorRead>(`/api/v1/connectors/mcp/${id}`, undefined, {
body: data,
});
};
/**
* Delete an MCP connector
*/
deleteMCPConnector = async (connectorId: number) => {
return baseApiService.delete<void>(`/api/v1/connectors/mcp/${connectorId}`);
};
/**
* Test MCP server connection and retrieve available tools
*/
testMCPConnection = async (serverConfig: MCPServerConfig) => {
return baseApiService.post<MCPTestConnectionResponse>(
"/api/v1/connectors/mcp/test",
undefined,
{
body: serverConfig,
}
);
};
}
export const connectorsApiService = new ConnectorsApiService();