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. It handles server lifecycle management, tool discovery, and tool execution.
""" """
import asyncio
import logging import logging
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@ -14,6 +15,11 @@ from mcp.client.stdio import StdioServerParameters, stdio_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Retry configuration
MAX_RETRIES = 3
RETRY_DELAY = 1.0 # seconds
RETRY_BACKOFF = 2.0 # exponential backoff multiplier
class MCPClient: class MCPClient:
"""Client for communicating with an MCP server.""" """Client for communicating with an MCP server."""
@ -35,44 +41,86 @@ class MCPClient:
self.session: ClientSession | None = None self.session: ClientSession | None = None
@asynccontextmanager @asynccontextmanager
async def connect(self): async def connect(self, max_retries: int = MAX_RETRIES):
"""Connect to the MCP server and manage its lifecycle. """Connect to the MCP server and manage its lifecycle.
Args:
max_retries: Maximum number of connection retry attempts
Yields: Yields:
ClientSession: Active MCP session for making requests ClientSession: Active MCP session for making requests
Raises:
RuntimeError: If all connection attempts fail
""" """
try: last_error = None
# Merge env vars with current environment delay = RETRY_DELAY
server_env = os.environ.copy()
server_env.update(self.env)
# Create server parameters with env for attempt in range(max_retries):
server_params = StdioServerParameters( try:
command=self.command, args=self.args, env=server_env # Merge env vars with current environment
) server_env = os.environ.copy()
server_env.update(self.env)
# Spawn server process and create session # Create server parameters with env
# Note: Cannot combine these context managers because ClientSession server_params = StdioServerParameters(
# needs the read/write streams from stdio_client command=self.command, args=self.args, env=server_env
async with stdio_client(server=server_params) as (read, write): # noqa: SIM117 )
async with ClientSession(read, write) as session:
# Initialize the connection # Spawn server process and create session
await session.initialize() # Note: Cannot combine these context managers because ClientSession
self.session = session # needs the read/write streams from stdio_client
logger.info( async with stdio_client(server=server_params) as (read, write): # noqa: SIM117
"Connected to MCP server: %s %s", async with ClientSession(read, write) as session:
self.command, # Initialize the connection
" ".join(self.args), 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: # All retries exhausted
logger.error("Failed to connect to MCP server: %s", e, exc_info=True) error_msg = f"Failed to connect to MCP server '{self.command}' after {max_retries} attempts"
raise if last_error:
finally: error_msg += f": {last_error}"
self.session = None logger.error(error_msg)
logger.info("Disconnected from MCP server: %s", self.command) raise RuntimeError(error_msg) from last_error
async def list_tools(self) -> list[dict[str, Any]]: async def list_tools(self) -> list[dict[str, Any]]:
"""List all tools available from the MCP server. """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) input_model = _create_dynamic_input_model_from_schema(tool_name, input_schema)
async def mcp_tool_call(**kwargs) -> str: 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}") logger.info(f"MCP tool '{tool_name}' called with params: {kwargs}")
try: try:
# Connect to server and call tool # Connect to server and call tool (connect has built-in retry logic)
async with mcp_client.connect(): async with mcp_client.connect():
result = await mcp_client.call_tool(tool_name, kwargs) result = await mcp_client.call_tool(tool_name, kwargs)
return str(result) 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: 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) logger.exception(error_msg)
return f"Error: {error_msg}" return f"Error: {error_msg}"
@ -146,17 +152,38 @@ async def load_mcp_tools(
tools: list[StructuredTool] = [] tools: list[StructuredTool] = []
for connector in result.scalars(): for connector in result.scalars():
try: try:
# Extract server config # Early validation: Extract and validate connector config
config = connector.config or {} config = connector.config or {}
server_config = config.get("server_config", {}) server_config = config.get("server_config", {})
command = server_config.get("command") # Validate server_config exists and is a dict
args = server_config.get("args", []) if not server_config or not isinstance(server_config, dict):
env = server_config.get("env", {})
if not command:
logger.warning( 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 continue
@ -172,22 +199,21 @@ async def load_mcp_tools(
f"'{command}' (connector {connector.id})" f"'{command}' (connector {connector.id})"
) )
# Create LangChain tools from definitions # Create LangChain tools from definitions
for tool_def in tool_definitions: for tool_def in tool_definitions:
try: try:
tool = await _create_mcp_tool_from_definition( tool = await _create_mcp_tool_from_definition(
tool_def, mcp_client tool_def, mcp_client
) )
tools.append(tool) tools.append(tool)
except Exception as e: except Exception as e:
logger.exception( logger.exception(
f"Failed to create tool '{tool_def.get('name')}' " f"Failed to create tool '{tool_def.get('name')}' "
f"from connector {connector.id}: {e!s}", f"from connector {connector.id}: {e!s}"
) )
except Exception as e: except Exception as e:
logger.exception( 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}") 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", "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( db_connector = SearchSourceConnector(
name=connector_data.name, name=connector_data.name,
connector_type=SearchSourceConnectorType.MCP_CONNECTOR, connector_type=SearchSourceConnectorType.MCP_CONNECTOR,
@ -2125,7 +2125,7 @@ async def create_mcp_connector(
await session.refresh(db_connector) await session.refresh(db_connector)
logger.info( 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}" 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.""" """Schema for creating an MCP connector."""
name: str name: str
server_config: MCPServerConfig server_config: MCPServerConfig # Single MCP server configuration
class MCPConnectorUpdate(BaseModel): class MCPConnectorUpdate(BaseModel):
@ -106,7 +106,7 @@ class MCPConnectorUpdate(BaseModel):
class MCPConnectorRead(BaseModel): class MCPConnectorRead(BaseModel):
"""Schema for reading an MCP connector with server config.""" """Schema for reading an MCP connector with server configs."""
id: int id: int
name: str name: str
@ -123,7 +123,8 @@ class MCPConnectorRead(BaseModel):
def from_connector(cls, connector: SearchSourceConnectorRead) -> "MCPConnectorRead": def from_connector(cls, connector: SearchSourceConnectorRead) -> "MCPConnectorRead":
"""Convert from base SearchSourceConnectorRead.""" """Convert from base SearchSourceConnectorRead."""
config = connector.config or {} 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( return cls(
id=connector.id, 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 { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab";
import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab"; import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view"; 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"; import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
export const ConnectorIndicator: FC = () => { export const ConnectorIndicator: FC = () => {
@ -56,6 +57,7 @@ export const ConnectorIndicator: FC = () => {
frequencyMinutes, frequencyMinutes,
allConnectors, allConnectors,
viewingAccountsType, viewingAccountsType,
viewingMCPList,
setSearchQuery, setSearchQuery,
setStartDate, setStartDate,
setEndDate, setEndDate,
@ -79,6 +81,8 @@ export const ConnectorIndicator: FC = () => {
handleBackFromYouTube, handleBackFromYouTube,
handleViewAccountsList, handleViewAccountsList,
handleBackFromAccountsList, handleBackFromAccountsList,
handleBackFromMCPList,
handleAddNewMCPFromList,
handleQuickIndexConnector, handleQuickIndexConnector,
connectorConfig, connectorConfig,
setConnectorConfig, setConnectorConfig,
@ -95,15 +99,15 @@ export const ConnectorIndicator: FC = () => {
refreshConnectors: refreshConnectorsElectric, refreshConnectors: refreshConnectorsElectric,
} = useConnectorsElectric(searchSpaceId); } = useConnectorsElectric(searchSpaceId);
// Fallback to API if Electric fails or is not available // Fallback to API if Electric is not available or fails
const connectors = // Use Electric data if: 1) we have data, or 2) still loading without error
connectorsFromElectric.length > 0 || !connectorsError // Use API data if: Electric failed (has error) or finished loading with no data
? connectorsFromElectric const useElectricData = connectorsFromElectric.length > 0 || (connectorsLoading && !connectorsError);
: allConnectors || []; const connectors = useElectricData ? connectorsFromElectric : allConnectors || [];
// Manual refresh function that works with both Electric and API // Manual refresh function that works with both Electric and API
const refreshConnectors = async () => { const refreshConnectors = async () => {
if (connectorsFromElectric.length > 0 || !connectorsError) { if (useElectricData) {
await refreshConnectorsElectric(); await refreshConnectorsElectric();
} else { } else {
// Fallback: use allConnectors from useConnectorDialog (which uses connectorsAtom) // Fallback: use allConnectors from useConnectorDialog (which uses connectorsAtom)
@ -126,7 +130,8 @@ export const ConnectorIndicator: FC = () => {
const hasConnectors = connectors.length > 0; const hasConnectors = connectors.length > 0;
const hasSources = hasConnectors || activeDocumentTypes.length > 0; const hasSources = hasConnectors || activeDocumentTypes.length > 0;
const totalSourceCount = connectors.length + activeDocumentTypes.length; 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 // Check which connectors are already connected
// Using Electric SQL + PGlite for real-time connector updates // 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 */} {/* YouTube Crawler View - shown when adding YouTube videos */}
{isYouTubeView && searchSpaceId ? ( {isYouTubeView && searchSpaceId ? (
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} /> <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 ? ( ) : viewingAccountsType ? (
<ConnectorAccountsListView <ConnectorAccountsListView
connectorType={viewingAccountsType.connectorType} connectorType={viewingAccountsType.connectorType}
@ -210,6 +228,8 @@ export const ConnectorIndicator: FC = () => {
isSaving={isSaving} isSaving={isSaving}
isDisconnecting={isDisconnecting} isDisconnecting={isDisconnecting}
isIndexing={indexingConnectorIds.has(editingConnector.id)} isIndexing={indexingConnectorIds.has(editingConnector.id)}
searchSpaceId={searchSpaceId?.toString()}
onStartDateChange={setStartDate} onStartDateChange={setStartDate}
onEndDateChange={setEndDate} onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled} 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 { GithubConnectForm } from "./components/github-connect-form";
import { LinkupApiConnectForm } from "./components/linkup-api-connect-form"; import { LinkupApiConnectForm } from "./components/linkup-api-connect-form";
import { LumaConnectForm } from "./components/luma-connect-form"; import { LumaConnectForm } from "./components/luma-connect-form";
import { MCPConnectForm } from "./components/mcp-connect-form";
import { SearxngConnectForm } from "./components/searxng-connect-form"; import { SearxngConnectForm } from "./components/searxng-connect-form";
import { TavilyApiConnectForm } from "./components/tavily-api-connect-form"; import { TavilyApiConnectForm } from "./components/tavily-api-connect-form";
@ -15,6 +16,7 @@ export interface ConnectFormProps {
connector_type: string; connector_type: string;
config: Record<string, unknown>; config: Record<string, unknown>;
is_indexable: boolean; is_indexable: boolean;
is_active: boolean;
last_indexed_at: null; last_indexed_at: null;
periodic_indexing_enabled: boolean; periodic_indexing_enabled: boolean;
indexing_frequency_minutes: number | null; indexing_frequency_minutes: number | null;
@ -54,6 +56,8 @@ export function getConnectFormComponent(connectorType: string): ConnectFormCompo
return LumaConnectForm; return LumaConnectForm;
case "CIRCLEBACK_CONNECTOR": case "CIRCLEBACK_CONNECTOR":
return CirclebackConnectForm; return CirclebackConnectForm;
case "MCP_CONNECTOR":
return MCPConnectForm;
// Add other connector types here as needed // Add other connector types here as needed
default: default:
return null; 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 { JiraConfig } from "./components/jira-config";
import { LinkupApiConfig } from "./components/linkup-api-config"; import { LinkupApiConfig } from "./components/linkup-api-config";
import { LumaConfig } from "./components/luma-config"; import { LumaConfig } from "./components/luma-config";
import { MCPConfig } from "./components/mcp-config";
import { SearxngConfig } from "./components/searxng-config"; import { SearxngConfig } from "./components/searxng-config";
import { SlackConfig } from "./components/slack-config"; import { SlackConfig } from "./components/slack-config";
import { TavilyApiConfig } from "./components/tavily-api-config"; import { TavilyApiConfig } from "./components/tavily-api-config";
@ -24,6 +25,7 @@ export interface ConnectorConfigProps {
connector: SearchSourceConnector; connector: SearchSourceConnector;
onConfigChange?: (config: Record<string, unknown>) => void; onConfigChange?: (config: Record<string, unknown>) => void;
onNameChange?: (name: string) => void; onNameChange?: (name: string) => void;
searchSpaceId?: string;
} }
export type ConnectorConfigComponent = FC<ConnectorConfigProps>; export type ConnectorConfigComponent = FC<ConnectorConfigProps>;
@ -69,6 +71,8 @@ export function getConnectorConfigComponent(
return LumaConfig; return LumaConfig;
case "CIRCLEBACK_CONNECTOR": case "CIRCLEBACK_CONNECTOR":
return CirclebackConfig; return CirclebackConfig;
case "MCP_CONNECTOR":
return MCPConfig;
// OAuth connectors (Gmail, Calendar, Airtable, Notion) and others don't need special config UI // OAuth connectors (Gmail, Calendar, Airtable, Notion) and others don't need special config UI
default: default:
return null; return null;

View file

@ -56,6 +56,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
GITHUB_CONNECTOR: "github-connect-form", GITHUB_CONNECTOR: "github-connect-form",
LUMA_CONNECTOR: "luma-connect-form", LUMA_CONNECTOR: "luma-connect-form",
CIRCLEBACK_CONNECTOR: "circleback-connect-form", CIRCLEBACK_CONNECTOR: "circleback-connect-form",
MCP_CONNECTOR: "mcp-connect-form",
}; };
const formId = formIdMap[connectorType]; const formId = formIdMap[connectorType];
if (formId) { if (formId) {
@ -98,7 +99,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
</div> </div>
<div> <div>
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight"> <h2 className="text-xl sm:text-2xl font-semibold tracking-tight">
Connect {getConnectorTypeDisplay(connectorType)} Connect {connectorType === "MCP_CONNECTOR" ? "MCP Server" : getConnectorTypeDisplay(connectorType)}
</h2> </h2>
<p className="text-xs sm:text-base text-muted-foreground mt-1"> <p className="text-xs sm:text-base text-muted-foreground mt-1">
Enter your connection details Enter your connection details
@ -138,7 +139,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
Connecting Connecting
</> </>
) : ( ) : (
<>Connect {getConnectorTypeDisplay(connectorType)}</> <>{connectorType === "MCP_CONNECTOR" ? "Connect" : `Connect ${getConnectorTypeDisplay(connectorType)}`}</>
)} )}
</Button> </Button>
</div> </div>

View file

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

View file

@ -160,6 +160,12 @@ export const OTHER_CONNECTORS = [
description: "Receive meeting notes, transcripts", description: "Receive meeting notes, transcripts",
connectorType: EnumConnectorName.CIRCLEBACK_CONNECTOR, connectorType: EnumConnectorName.CIRCLEBACK_CONNECTOR,
}, },
{
id: "mcp-connector",
title: "MCPs",
description: "Connect to MCP servers for AI tools",
connectorType: EnumConnectorName.MCP_CONNECTOR,
},
] as const; ] as const;
// Re-export IndexingConfigState from schemas for backward compatibility // 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({ export const connectorPopupQueryParamsSchema = z.object({
modal: z.enum(["connectors"]).optional(), modal: z.enum(["connectors"]).optional(),
tab: z.enum(["all", "active"]).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(), connector: z.string().optional(),
connectorId: z.string().optional(), connectorId: z.string().optional(),
connectorType: z.string().optional(), connectorType: z.string().optional(),

View file

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

View file

@ -3,11 +3,15 @@
import { ArrowRight, Cable, Loader2 } from "lucide-react"; import { ArrowRight, Cable, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { FC } from "react"; import type { FC } from "react";
import { useState } from "react";
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { TabsContent } from "@/components/ui/tabs"; import { TabsContent } from "@/components/ui/tabs";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types"; 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 { cn } from "@/lib/utils";
import { OAUTH_CONNECTORS } from "../constants/connector-constants"; import { OAUTH_CONNECTORS } from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping"; import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
@ -25,6 +29,14 @@ interface ActiveConnectorsTabProps {
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void; 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> = ({ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
searchQuery, searchQuery,
hasSources, hasSources,
@ -84,7 +96,9 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
// Separate OAuth and non-OAuth connectors // Separate OAuth and non-OAuth connectors
const oauthConnectors = connectors.filter((c) => oauthConnectorTypes.has(c.connector_type)); 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 // Group OAuth connectors by type
const oauthConnectorsByType = oauthConnectors.reduce( const oauthConnectorsByType = oauthConnectors.reduce(
@ -136,7 +150,8 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
}); });
const hasActiveConnectors = const hasActiveConnectors =
filteredOAuthConnectorTypes.length > 0 || filteredNonOAuthConnectors.length > 0; filteredOAuthConnectorTypes.length > 0 ||
filteredNonOAuthConnectors.length > 0;
return ( return (
<TabsContent value="active" className="m-0"> <TabsContent value="active" className="m-0">
@ -225,7 +240,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
connector.connector_type, connector.connector_type,
documentTypeCounts documentTypeCounts
); );
const isMCPConnector = connector.connector_type === "MCP_CONNECTOR";
return ( return (
<div <div
key={`connector-${connector.id}`} key={`connector-${connector.id}`}
@ -247,19 +262,21 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
{getConnectorIcon(connector.connector_type, "size-6")} {getConnectorIcon(connector.connector_type, "size-6")}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-[14px] font-semibold leading-tight truncate"> <div className="flex items-center gap-2">
{connector.name} <p className="text-[14px] font-semibold leading-tight">
</p> {connector.name}
</p>
</div>
{isIndexing ? ( {isIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5"> <p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" /> <Loader2 className="size-3 animate-spin" />
Syncing Syncing
</p> </p>
) : ( ) : !isMCPConnector ? (
<p className="text-[10px] text-muted-foreground mt-1"> <p className="text-[10px] text-muted-foreground mt-1">
{formatDocumentCount(documentCount)} {formatDocumentCount(documentCount)}
</p> </p>
)} ) : null}
</div> </div>
<Button <Button
variant="secondary" 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( const documentCount = getDocumentCountForConnector(
connector.connectorType, connector.connectorType,
documentTypeCounts documentTypeCounts
@ -107,7 +121,8 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
isConnected={isConnected} isConnected={isConnected}
isConnecting={isConnecting} isConnecting={isConnecting}
documentCount={documentCount} documentCount={documentCount}
accountCount={typeConnectors.length} accountCount={accountCount}
isIndexing={isIndexing} isIndexing={isIndexing}
onConnect={() => onConnectOAuth(connector)} onConnect={() => onConnectOAuth(connector)}
onManage={ onManage={
@ -161,6 +176,7 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
isConnected={isConnected} isConnected={isConnected}
isConnecting={isConnecting} isConnecting={isConnecting}
documentCount={documentCount} documentCount={documentCount}
isIndexing={isIndexing} isIndexing={isIndexing}
onConnect={handleConnect} onConnect={handleConnect}
onManage={ 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; 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 * Format last indexed date with contextual messages
*/ */
@ -166,9 +174,11 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
</p> </p>
) : ( ) : (
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap truncate"> <p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap truncate">
{connector.last_indexed_at {isIndexableConnector(connector.connector_type)
? `Last indexed: ${formatLastIndexedDate(connector.last_indexed_at)}` ? connector.last_indexed_at
: "Never indexed"} ? `Last indexed: ${formatLastIndexedDate(connector.last_indexed_at)}`
: "Never indexed"
: "Active"}
</p> </p>
)} )}
</div> </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", WEBCRAWLER_CONNECTOR = "WEBCRAWLER_CONNECTOR",
YOUTUBE_CONNECTOR = "YOUTUBE_CONNECTOR", YOUTUBE_CONNECTOR = "YOUTUBE_CONNECTOR",
CIRCLEBACK_CONNECTOR = "CIRCLEBACK_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} />; return <Image src="/connectors/youtube.svg" alt="YouTube" {...imgProps} />;
case EnumConnectorName.CIRCLEBACK_CONNECTOR: case EnumConnectorName.CIRCLEBACK_CONNECTOR:
return <IconUsersGroup {...iconProps} />; return <IconUsersGroup {...iconProps} />;
case EnumConnectorName.MCP_CONNECTOR:
return <Webhook {...iconProps} />;
// Additional cases for non-enum connector types // Additional cases for non-enum connector types
case "YOUTUBE_CONNECTOR": case "YOUTUBE_CONNECTOR":
return <Image src="/connectors/youtube.svg" alt="YouTube" {...imgProps} />; return <Image src="/connectors/youtube.svg" alt="YouTube" {...imgProps} />;

View file

@ -26,6 +26,7 @@ export const searchSourceConnectorTypeEnum = z.enum([
"YOUTUBE_CONNECTOR", "YOUTUBE_CONNECTOR",
"BOOKSTACK_CONNECTOR", "BOOKSTACK_CONNECTOR",
"CIRCLEBACK_CONNECTOR", "CIRCLEBACK_CONNECTOR",
"MCP_CONNECTOR",
]); ]);
export const searchSourceConnector = z.object({ export const searchSourceConnector = z.object({
@ -33,6 +34,7 @@ export const searchSourceConnector = z.object({
name: z.string(), name: z.string(),
connector_type: searchSourceConnectorTypeEnum, connector_type: searchSourceConnectorTypeEnum,
is_indexable: z.boolean(), is_indexable: z.boolean(),
is_active: z.boolean().default(true),
last_indexed_at: z.string().nullable(), last_indexed_at: z.string().nullable(),
config: z.record(z.string(), z.any()), config: z.record(z.string(), z.any()),
periodic_indexing_enabled: z.boolean(), periodic_indexing_enabled: z.boolean(),
@ -85,6 +87,7 @@ export const createConnectorRequest = z.object({
name: true, name: true,
connector_type: true, connector_type: true,
is_indexable: true, is_indexable: true,
is_active: true,
last_indexed_at: true, last_indexed_at: true,
config: true, config: true,
periodic_indexing_enabled: true, periodic_indexing_enabled: true,
@ -108,6 +111,7 @@ export const updateConnectorRequest = z.object({
name: true, name: true,
connector_type: true, connector_type: true,
is_indexable: true, is_indexable: true,
is_active: true,
last_indexed_at: true, last_indexed_at: true,
config: true, config: true,
periodic_indexing_enabled: 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 // Start syncing when Electric client is available
useEffect(() => { useEffect(() => {
// Wait for both searchSpaceId and Electric client to be available // If no Electric client available, immediately mark as not loading (disabled)
if (!searchSpaceId || !electricClient) { if (!electricClient) {
setLoading(!electricClient); // Still loading if waiting for Electric setLoading(false);
if (!searchSpaceId) { setError(new Error("Electric SQL not configured"));
setConnectors([]); return;
} }
// Wait for searchSpaceId to be available
if (!searchSpaceId) {
setConnectors([]);
setLoading(false);
return; return;
} }

View file

@ -24,6 +24,14 @@ import {
updateConnectorRequest, updateConnectorRequest,
updateConnectorResponse, updateConnectorResponse,
} from "@/contracts/types/connector.types"; } from "@/contracts/types/connector.types";
import type {
CreateMCPConnectorRequest,
GetMCPConnectorsRequest,
MCPConnectorRead,
MCPServerConfig,
MCPTestConnectionResponse,
UpdateMCPConnectorRequest,
} from "@/contracts/types/mcp.types";
import { ValidationError } from "../error"; import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service"; import { baseApiService } from "./base-api.service";
@ -224,6 +232,76 @@ class ConnectorsApiService {
listGoogleDriveFoldersResponse 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(); export const connectorsApiService = new ConnectorsApiService();