diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx
index dcd20cefd..4d141d4ea 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx
@@ -14,12 +14,10 @@ import type { ConnectorConfigProps } from "../index";
interface MCPConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void;
- searchSpaceId?: string;
- onOtherMCPConnectorsLoaded?: (connectorIds: number[]) => void;
}
-export const MCPConfig: FC = ({ connector, onConfigChange, onNameChange, searchSpaceId, onOtherMCPConnectorsLoaded }) => {
- const [name, setName] = useState("MCPs");
+export const MCPConfig: FC = ({ connector, onConfigChange, onNameChange }) => {
+ const [name, setName] = useState("");
const [configJson, setConfigJson] = useState("");
const [jsonError, setJsonError] = useState(null);
const [isTesting, setIsTesting] = useState(false);
@@ -29,55 +27,26 @@ export const MCPConfig: FC = ({ connector, onConfigChange, onNam
message: string;
tools: MCPToolDefinition[];
} | null>(null);
- const [allMCPConnectors, setAllMCPConnectors] = useState([]);
- // Load all MCP connectors for this search space
+ // Initialize form from connector config (only on mount)
useEffect(() => {
- const loadAllMCPConnectors = async () => {
- if (!searchSpaceId) return;
-
- try {
- const connectors = await connectorsApiService.getConnectors({
- queryParams: { search_space_id: parseInt(searchSpaceId, 10) }
- });
- const mcpConnectors = connectors.filter((c: any) => c.connector_type === "MCP_CONNECTOR");
- setAllMCPConnectors(mcpConnectors);
-
- // Notify parent about other MCP connectors that should be deleted on save
- const otherConnectorIds = mcpConnectors
- .filter((c: any) => c.id !== connector.id)
- .map((c: any) => c.id);
- if (onOtherMCPConnectorsLoaded && otherConnectorIds.length > 0) {
- onOtherMCPConnectorsLoaded(otherConnectorIds);
- }
-
- // Collect all server configs from all MCP connectors
- const allServerConfigs: MCPServerConfig[] = [];
- for (const mcpConn of mcpConnectors) {
- const serverConfigs = mcpConn.config?.server_configs as MCPServerConfig[] | undefined;
- if (serverConfigs && Array.isArray(serverConfigs)) {
- allServerConfigs.push(...serverConfigs);
- }
- }
-
- if (allServerConfigs.length > 0) {
- setConfigJson(JSON.stringify(allServerConfigs, null, 2));
- } else {
- setConfigJson(JSON.stringify([{
- command: "",
- args: [],
- env: {},
- transport: "stdio",
- }], null, 2));
- }
- } catch (error) {
- console.error("Failed to load MCP connectors:", error);
- }
- };
+ if (connector.name) {
+ setName(connector.name);
+ }
- loadAllMCPConnectors();
- }, [searchSpaceId]);
-
+ 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);
@@ -86,31 +55,31 @@ export const MCPConfig: FC = ({ connector, onConfigChange, onNam
}
};
- const parseConfig = (): MCPServerConfig[] | null => {
+ const parseConfig = (): MCPServerConfig | null => {
try {
const parsed = JSON.parse(configJson);
-
- // Handle both single object and array
- const configs = Array.isArray(parsed) ? parsed : [parsed];
-
- // Validate each config
- const validConfigs: MCPServerConfig[] = [];
- for (let i = 0; i < configs.length; i++) {
- const cfg = configs[i];
- if (!cfg.command || typeof cfg.command !== "string") {
- setJsonError(`Config ${i + 1}: 'command' field is required and must be a string`);
- return null;
- }
- validConfigs.push({
- command: cfg.command,
- args: cfg.args || [],
- env: cfg.env || {},
- transport: cfg.transport || "stdio",
- });
+
+ // Validate that it's an object, not an array
+ if (Array.isArray(parsed)) {
+ setJsonError("Please provide a single server configuration object, not an array");
+ return null;
}
-
+
+ // Validate required fields
+ if (!parsed.command || typeof parsed.command !== "string") {
+ setJsonError("'command' field is required and must be a string");
+ return null;
+ }
+
+ const config: MCPServerConfig = {
+ command: parsed.command,
+ args: parsed.args || [],
+ env: parsed.env || {},
+ transport: parsed.transport || "stdio",
+ };
+
setJsonError(null);
- return validConfigs;
+ return config;
} catch (error) {
setJsonError(error instanceof Error ? error.message : "Invalid JSON");
return null;
@@ -119,51 +88,32 @@ export const MCPConfig: FC = ({ connector, onConfigChange, onNam
const handleConfigChange = (value: string) => {
setConfigJson(value);
- // Clear error when user starts typing
if (jsonError) {
setJsonError(null);
}
- // Treat empty/whitespace-only input as empty array (user wants to remove all servers)
- const trimmedValue = value.trim();
- if (trimmedValue === "") {
- if (onConfigChange) {
- onConfigChange({ server_configs: [] });
- }
- return;
- }
-
// Try to parse and update parent if valid
try {
const parsed = JSON.parse(value);
- const configs = Array.isArray(parsed) ? parsed : [parsed];
-
- // Validate each config
- const validConfigs: MCPServerConfig[] = [];
- for (const cfg of configs) {
- if (cfg.command && typeof cfg.command === "string") {
- validConfigs.push({
- command: cfg.command,
- args: cfg.args || [],
- env: cfg.env || {},
- transport: cfg.transport || "stdio",
- });
+ if (!Array.isArray(parsed) && parsed.command) {
+ const config: MCPServerConfig = {
+ command: parsed.command,
+ args: parsed.args || [],
+ env: parsed.env || {},
+ transport: parsed.transport || "stdio",
+ };
+ if (onConfigChange) {
+ onConfigChange({ server_config: config });
}
}
-
- // Always update parent with configs (including empty array)
- // Empty array signals that all servers should be removed
- if (onConfigChange) {
- onConfigChange({ server_configs: validConfigs });
- }
} catch {
- // Ignore parse errors while typing - don't update parent with invalid config
+ // Ignore parse errors while typing
}
};
const handleTestConnection = async () => {
- const serverConfigs = parseConfig();
- if (!serverConfigs || serverConfigs.length === 0) {
+ const serverConfig = parseConfig();
+ if (!serverConfig) {
setTestResult({
status: "error",
message: jsonError || "Invalid configuration",
@@ -172,55 +122,34 @@ export const MCPConfig: FC = ({ connector, onConfigChange, onNam
return;
}
- // Update parent with the config array
+ // Update parent with the config
if (onConfigChange) {
- onConfigChange({ server_configs: serverConfigs });
+ onConfigChange({ server_config: serverConfig });
}
setIsTesting(true);
setTestResult(null);
try {
- // Test all servers and collect results
- const allTools: MCPToolDefinition[] = [];
- const errors: string[] = [];
+ const result = await connectorsApiService.testMCPConnection(serverConfig);
- for (const serverConfig of serverConfigs) {
- try {
- const result = await connectorsApiService.testMCPConnection(serverConfig);
- if (result.status === "success") {
- allTools.push(...result.tools);
- } else {
- errors.push(`${serverConfig.command}: ${result.message}`);
- }
- } catch (error) {
- errors.push(`${serverConfig.command}: ${error instanceof Error ? error.message : "Failed to connect"}`);
- }
- }
-
- if (errors.length === 0) {
+ if (result.status === "success") {
setTestResult({
status: "success",
- message: `Successfully connected to ${serverConfigs.length} server(s)`,
- tools: allTools,
- });
- } else if (allTools.length > 0) {
- setTestResult({
- status: "success",
- message: `Partially successful. Errors: ${errors.join("; ")}`,
- tools: allTools,
+ message: `Connected successfully! Found ${result.tools.length} tool(s).`,
+ tools: result.tools,
});
} else {
setTestResult({
status: "error",
- message: errors.join("; "),
+ message: result.message || "Failed to connect",
tools: [],
});
}
} catch (error) {
setTestResult({
status: "error",
- message: error instanceof Error ? error.message : "Failed to connect to MCP servers",
+ message: error instanceof Error ? error.message : "Failed to connect",
tools: [],
});
} finally {
@@ -230,6 +159,18 @@ export const MCPConfig: FC = ({ connector, onConfigChange, onNam
return (
+ {/* Server Name */}
+
+ Server Name *
+ handleNameChange(e.target.value)}
+ placeholder="e.g., Filesystem Server"
+ required
+ />
+
+
{/* Server Configuration */}
@@ -239,21 +180,19 @@ export const MCPConfig: FC = ({ connector, onConfigChange, onNam
+
MCP Server Configuration (JSON)
@@ -266,7 +205,7 @@ export const MCPConfig: FC
= ({ connector, onConfigChange, onNam
variant="outline"
className="w-full"
>
- {isTesting ? "Testing..." : "Test Connection"}
+ {isTesting ? "Testing Connection..." : "Test Connection"}
@@ -280,9 +219,9 @@ export const MCPConfig: FC = ({ connector, onConfigChange, onNam
}
>
{testResult.status === "success" ? (
-
+
) : (
-
+
)}
@@ -317,10 +256,10 @@ export const MCPConfig: FC = ({ connector, onConfigChange, onNam
{testResult.message}
- {showDetails && testResult.status === "success" && testResult.tools.length > 0 && (
+ {showDetails && testResult.tools.length > 0 && (
- Found {testResult.tools.length} tool{testResult.tools.length !== 1 ? 's' : ''}:
+ Available tools:
{testResult.tools.map((tool, i) => (
diff --git a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-popup.schemas.ts b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-popup.schemas.ts
index a1b303163..d74d66203 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-popup.schemas.ts
+++ b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-popup.schemas.ts
@@ -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(),
diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
index 6cfc279ac..8583da7bf 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
+++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
@@ -68,7 +68,6 @@ export const useConnectorDialog = () => {
const [isDisconnecting, setIsDisconnecting] = useState(false);
const [connectorConfig, setConnectorConfig] = useState | null>(null);
const [connectorName, setConnectorName] = useState(null);
- const [otherMCPConnectorIds, setOtherMCPConnectorIds] = useState([]);
// Connect mode state (for non-OAuth connectors)
const [connectingConnectorType, setConnectingConnectorType] = useState(null);
@@ -81,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) {
@@ -140,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);
@@ -623,32 +638,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
@@ -685,7 +702,10 @@ 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);
// Close modal and return to main view
const url = new URL(window.location.href);
@@ -729,11 +749,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(() => {
@@ -776,6 +803,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) => {
@@ -961,6 +1020,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);
@@ -977,6 +1043,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(
@@ -1006,7 +1079,7 @@ 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
@@ -1047,82 +1120,6 @@ export const useConnectorDialog = () => {
const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined;
const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined;
- // For MCP connectors, track original server count for toast messages
- let originalServerCount = 0;
- let newServerCount = 0;
- if (editingConnector.connector_type === "MCP_CONNECTOR") {
- const originalServerConfigs = editingConnector.config?.server_configs;
- originalServerCount = Array.isArray(originalServerConfigs) ? originalServerConfigs.length : 0;
- const newServerConfigs = connectorConfig?.server_configs;
- newServerCount = Array.isArray(newServerConfigs) ? newServerConfigs.length : 0;
- }
-
- // For MCP connectors, check if all servers were removed (empty array)
- if (editingConnector.connector_type === "MCP_CONNECTOR") {
- const serverConfigs = connectorConfig?.server_configs;
- if (!serverConfigs || (Array.isArray(serverConfigs) && serverConfigs.length === 0)) {
- // All servers removed - delete the entire connector
- await deleteConnector({
- id: editingConnector.id,
- });
-
- // Also delete other MCP connectors that were consolidated
- if (otherMCPConnectorIds.length > 0) {
- await Promise.all(
- otherMCPConnectorIds.map((id) =>
- deleteConnector({
- id,
- }).catch(() => {
- // Silently ignore errors for individual deletions
- })
- )
- );
- setOtherMCPConnectorIds([]);
- }
-
- toast.success("MCPs disconnected successfully", {
- description: "All MCP servers have been removed.",
- });
-
- // Update URL to 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");
- router.replace(url.pathname + url.search, { scroll: false });
-
- // Refresh connectors and reset state
- refreshConnectors();
- setEditingConnector(null);
- setConnectorName("");
- setConnectorConfig(null);
- setPeriodicEnabled(false);
- setFrequencyMinutes("1440");
- setStartDate(undefined);
- setEndDate(undefined);
- setOtherMCPConnectorIds([]);
-
- setIsSaving(false);
- return;
- }
- }
-
- // For MCP connectors, delete other MCP connectors first (consolidate all into one)
- if (editingConnector.connector_type === "MCP_CONNECTOR" && otherMCPConnectorIds.length > 0) {
- // Silently delete other MCP connectors without showing toasts
- await Promise.all(
- otherMCPConnectorIds.map((id) =>
- deleteConnector({
- id,
- }).catch(() => {
- // Silently ignore errors for individual deletions
- })
- )
- );
- setOtherMCPConnectorIds([]);
- }
-
// Update connector with periodic sync settings, config changes, and name
// Note: Periodic sync is disabled for Google Drive connectors and non-indexable connectors
const frequency =
@@ -1228,17 +1225,7 @@ export const useConnectorDialog = () => {
}
// Generate toast message based on connector type
- let toastTitle = `${editingConnector.name} updated successfully`;
- if (editingConnector.connector_type === "MCP_CONNECTOR") {
- const serverDiff = newServerCount - originalServerCount;
- if (serverDiff > 0) {
- toastTitle = `${serverDiff} MCP ${serverDiff === 1 ? "server" : "servers"} added`;
- } else if (serverDiff < 0) {
- toastTitle = `${Math.abs(serverDiff)} MCP ${Math.abs(serverDiff) === 1 ? "server" : "servers"} removed`;
- } else {
- toastTitle = "MCPs updated successfully";
- }
- }
+ const toastTitle = `${editingConnector.name} updated successfully`;
toast.success(toastTitle, {
description: periodicEnabled
@@ -1279,8 +1266,6 @@ export const useConnectorDialog = () => {
router,
connectorConfig,
connectorName,
- otherMCPConnectorIds,
- deleteConnector,
]
);
@@ -1304,16 +1289,24 @@ export const useConnectorDialog = () => {
toast.success(
editingConnector.connector_type === "MCP_CONNECTOR"
- ? "MCPs disconnected successfully"
+ ? `${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, go back to list; for others, 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") {
+ // Go back to MCP list view
+ setViewingMCPList(true);
+ url.searchParams.set("modal", "connectors");
+ url.searchParams.set("view", "mcp-list");
+ url.searchParams.delete("connectorId");
+ } else {
+ 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();
@@ -1365,6 +1358,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
@@ -1377,10 +1385,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 });
@@ -1388,7 +1396,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(
@@ -1466,6 +1474,7 @@ export const useConnectorDialog = () => {
searchSpaceId,
allConnectors,
viewingAccountsType,
+ viewingMCPList,
// Setters
setSearchQuery,
@@ -1474,7 +1483,6 @@ export const useConnectorDialog = () => {
setPeriodicEnabled,
setFrequencyMinutes,
setConnectorName,
- setOtherMCPConnectorIds,
// Handlers
handleOpenChange,
@@ -1495,6 +1503,9 @@ export const useConnectorDialog = () => {
handleBackFromYouTube,
handleViewAccountsList,
handleBackFromAccountsList,
+ handleViewMCPList,
+ handleBackFromMCPList,
+ handleAddNewMCPFromList,
handleQuickIndexConnector,
connectorConfig,
setConnectorConfig,
diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx
index ba4a03084..b7edf3643 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx
@@ -123,19 +123,12 @@ export const ActiveConnectorsTab: FC = ({
// Get OAuth connector types set for quick lookup
const oauthConnectorTypes = new Set(OAUTH_CONNECTORS.map((c) => c.connectorType));
- // Separate OAuth, MCP, and other non-OAuth connectors
+ // Separate OAuth and non-OAuth connectors
const oauthConnectors = connectors.filter((c) => oauthConnectorTypes.has(c.connector_type));
- const mcpConnectors = connectors.filter((c) => c.connector_type === "MCP_CONNECTOR");
const nonOauthConnectors = connectors.filter(
- (c) => !oauthConnectorTypes.has(c.connector_type) && c.connector_type !== "MCP_CONNECTOR"
+ (c) => !oauthConnectorTypes.has(c.connector_type)
);
- // Calculate total number of MCP servers across all MCP connectors
- const totalMCPServers = mcpConnectors.reduce((total, connector) => {
- const serverConfigs = connector.config?.server_configs;
- return total + (Array.isArray(serverConfigs) ? serverConfigs.length : 0);
- }, 0);
-
// Group OAuth connectors by type
const oauthConnectorsByType = oauthConnectors.reduce(
(acc, connector) => {
@@ -185,17 +178,9 @@ export const ActiveConnectorsTab: FC = ({
);
});
- // Check if MCPs match search query
- const showMCPs =
- mcpConnectors.length > 0 &&
- (!searchQuery ||
- "mcps".includes(searchQuery.toLowerCase()) ||
- "model context protocol".includes(searchQuery.toLowerCase()));
-
const hasActiveConnectors =
filteredOAuthConnectorTypes.length > 0 ||
- filteredNonOAuthConnectors.length > 0 ||
- showMCPs;
+ filteredNonOAuthConnectors.length > 0;
return (
@@ -217,16 +202,8 @@ export const ActiveConnectorsTab: FC = ({
const documentCount = getDocumentCountForConnector(
connectorType,
documentTypeCounts
- );
- // Calculate account count - for MCP, count servers; for others, count connectors
- const accountCount =
- connectorType === "MCP_CONNECTOR"
- ? typeConnectors.reduce((total, c) => {
- const serverConfigs = c.config?.server_configs;
- return total + (Array.isArray(serverConfigs) ? serverConfigs.length : 0);
- }, 0)
- : typeConnectors.length;
- const mostRecentLastIndexed = getMostRecentLastIndexed(typeConnectors);
+ );
+ const accountCount = typeConnectors.length;
const handleManageClick = () => {
if (onViewAccountsList) {
@@ -298,41 +275,6 @@ export const ActiveConnectorsTab: FC = ({
);
})}
- {/* MCP Connectors - Single Grouped Card */}
- {showMCPs && (
-
-
- {getConnectorIcon("MCP_CONNECTOR", "size-6")}
-
-
-
MCPs
-
- {totalMCPServers} {totalMCPServers === 1 ? "Server" : "Servers"}
-
-
-
onManage(mcpConnectors[0]) : undefined
- }
- >
- Manage
-
-
- )}
-
{/* Non-OAuth Connectors - Individual Cards */}
{filteredNonOAuthConnectors.map((connector) => {
const isIndexing = indexingConnectorIds.has(connector.id);
diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx
index a51b7d26a..0653a1cbe 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx
@@ -103,14 +103,7 @@ export const AllConnectorsTab: FC = ({
)
: [];
- // Calculate account count - for MCP, count servers; for others, count connectors
- const accountCount =
- connector.connectorType === "MCP_CONNECTOR"
- ? typeConnectors.reduce((total, c) => {
- const serverConfigs = c.config?.server_configs;
- return total + (Array.isArray(serverConfigs) ? serverConfigs.length : 0);
- }, 0)
- : typeConnectors.length;
+ const accountCount = typeConnectors.length;
// Get the most recent last_indexed_at across all accounts
const mostRecentLastIndexed = typeConnectors.reduce(
diff --git a/surfsense_web/components/assistant-ui/connector-popup/views/mcp-connector-list-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/views/mcp-connector-list-view.tsx
new file mode 100644
index 000000000..2da6352a9
--- /dev/null
+++ b/surfsense_web/components/assistant-ui/connector-popup/views/mcp-connector-list-view.tsx
@@ -0,0 +1,122 @@
+"use client";
+
+import { Plus, Server } from "lucide-react";
+import type { FC } from "react";
+import { Button } from "@/components/ui/button";
+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 = ({
+ mcpConnectors,
+ onAddNew,
+ onManageConnector,
+ onBack,
+}) => {
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+
MCP Connectors
+
+ Manage your Model Context Protocol servers
+
+
+
+
+
+ {/* Add New Button */}
+
+
+
+ Add New MCP Server
+
+
+
+ {/* MCP Connectors List */}
+
+ {mcpConnectors.length === 0 ? (
+
+
+
+
+
No MCP Servers
+
+ Get started by adding your first Model Context Protocol server
+
+
+ ) : (
+ mcpConnectors.map((connector) => {
+ // Extract server name from config
+ const serverName = connector.config?.server_config?.name || connector.name;
+
+ return (
+
+
+ {getConnectorIcon("MCP_CONNECTOR", "size-6")}
+
+
+
onManageConnector(connector)}
+ >
+ Manage
+
+
+ );
+ })
+ )}
+
+
+ );
+};
diff --git a/surfsense_web/contracts/types/mcp.types.ts b/surfsense_web/contracts/types/mcp.types.ts
index eaed344df..e25ffe3c5 100644
--- a/surfsense_web/contracts/types/mcp.types.ts
+++ b/surfsense_web/contracts/types/mcp.types.ts
@@ -15,19 +15,19 @@ export const mcpServerConfig = z.object({
*/
export const mcpConnectorCreate = z.object({
name: z.string().min(1, "Connector name is required"),
- server_configs: z.array(mcpServerConfig).min(1, "At least one server configuration is required"),
+ server_config: mcpServerConfig,
});
export const mcpConnectorUpdate = z.object({
name: z.string().min(1).optional(),
- server_configs: z.array(mcpServerConfig).optional(),
+ server_config: mcpServerConfig.optional(),
});
export const mcpConnectorRead = z.object({
id: z.number(),
name: z.string(),
connector_type: z.literal("MCP_CONNECTOR"),
- server_configs: z.array(mcpServerConfig),
+ server_config: mcpServerConfig,
search_space_id: z.number(),
user_id: z.string(),
created_at: z.string(),
From 1340121d5e61efa3c618205850537b0e6aee94b9 Mon Sep 17 00:00:00 2001
From: Manoj Aggarwal
Date: Fri, 16 Jan 2026 15:02:44 -0800
Subject: [PATCH 12/18] nit
---
.../connect-forms/components/mcp-connect-form.tsx | 15 ++++++---------
.../views/connector-connect-view.tsx | 4 ++--
.../views/connector-edit-view.tsx | 2 +-
.../connector-popup/hooks/use-connector-dialog.ts | 7 ++++---
.../tabs/active-connectors-tab.tsx | 2 ++
5 files changed, 15 insertions(+), 15 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx
index 1a79c6ca9..62d017509 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx
@@ -161,15 +161,12 @@ export const MCPConnectForm: FC = ({ onSubmit, isSubmitting })
return (
-
-
-
-
MCP Server
-
- Connect to an MCP (Model Context Protocol) server. Each MCP server is added as a separate connector.
-
-
-
+
+
+
+ Connect to an MCP (Model Context Protocol) server. Each MCP server is added as a separate connector.
+
+
- {connector.connector_type === "MCP_CONNECTOR" ? "MCPs" : connector.name}
+ {connector.connector_type === "MCP_CONNECTOR" ? "MCP Server" : connector.name}
Manage your connector settings and sync configuration
diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
index 8583da7bf..0ffda5ceb 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
+++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
@@ -1293,15 +1293,16 @@ export const useConnectorDialog = () => {
: `${editingConnector.name} disconnected successfully`
);
- // Update URL - for MCP, go back to list; for others, close modal
+ // Update URL - for MCP from list view, go back to list; otherwise close modal
const url = new URL(window.location.href);
- if (editingConnector.connector_type === "MCP_CONNECTOR") {
- // Go back to MCP list view
+ 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");
diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx
index b7edf3643..2e179e0c7 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx
@@ -327,6 +327,8 @@ export const ActiveConnectorsTab: FC = ({
? connector.last_indexed_at
? `Last indexed: ${formatLastIndexedDate(connector.last_indexed_at)}`
: "Never indexed"
+ : connector.connector_type === "MCP_CONNECTOR"
+ ? ""
: "Active"}
)}
From 1ad81583c0a8d653d347483793f04e559d04994a Mon Sep 17 00:00:00 2001
From: Manoj Aggarwal
Date: Fri, 16 Jan 2026 15:11:01 -0800
Subject: [PATCH 13/18] fix errors
---
.../assistant-ui/connector-popup.tsx | 3 +--
.../tabs/active-connectors-tab.tsx | 21 +++++++++----------
2 files changed, 11 insertions(+), 13 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx
index 664e13097..b3b785c6f 100644
--- a/surfsense_web/components/assistant-ui/connector-popup.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup.tsx
@@ -70,7 +70,6 @@ export const ConnectorIndicator: FC = () => {
setEndDate,
setPeriodicEnabled,
setFrequencyMinutes,
- setOtherMCPConnectorIds,
handleOpenChange,
handleTabChange,
handleScroll,
@@ -259,7 +258,7 @@ export const ConnectorIndicator: FC = () => {
isDisconnecting={isDisconnecting}
isIndexing={indexingConnectorIds.has(editingConnector.id)}
searchSpaceId={searchSpaceId?.toString()}
- onOtherMCPConnectorsLoaded={setOtherMCPConnectorIds}
+
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx
index 2e179e0c7..19e00cf07 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx
@@ -202,17 +202,16 @@ export const ActiveConnectorsTab: FC = ({
const documentCount = getDocumentCountForConnector(
connectorType,
documentTypeCounts
- );
- const accountCount = typeConnectors.length;
-
- const handleManageClick = () => {
- if (onViewAccountsList) {
- onViewAccountsList(connectorType, title);
- } else if (onManage && typeConnectors[0]) {
- onManage(typeConnectors[0]);
- }
- };
-
+ );
+ const accountCount = typeConnectors.length;
+ const mostRecentLastIndexed = getMostRecentLastIndexed(typeConnectors);
+ const handleManageClick = () => {
+ if (onViewAccountsList) {
+ onViewAccountsList(connectorType, title);
+ } else if (onManage && typeConnectors[0]) {
+ onManage(typeConnectors[0]);
+ }
+ };
return (
Date: Fri, 16 Jan 2026 15:15:34 -0800
Subject: [PATCH 14/18] remove unnecessary props
---
.../assistant-ui/connector-popup/connector-configs/index.tsx | 1 -
.../connector-configs/views/connector-edit-view.tsx | 4 +---
2 files changed, 1 insertion(+), 4 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx
index 4c1ff3ed2..b493ce746 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx
@@ -26,7 +26,6 @@ export interface ConnectorConfigProps {
onConfigChange?: (config: Record
) => void;
onNameChange?: (name: string) => void;
searchSpaceId?: string;
- onOtherMCPConnectorsLoaded?: (connectorIds: number[]) => void;
}
export type ConnectorConfigComponent = FC;
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx
index f880e30e8..128b96955 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx
@@ -20,7 +20,6 @@ interface ConnectorEditViewProps {
isDisconnecting: boolean;
isIndexing?: boolean;
searchSpaceId?: string;
- onOtherMCPConnectorsLoaded?: (connectorIds: number[]) => void;
onStartDateChange: (date: Date | undefined) => void;
onEndDateChange: (date: Date | undefined) => void;
onPeriodicEnabledChange: (enabled: boolean) => void;
@@ -43,7 +42,6 @@ export const ConnectorEditView: FC = ({
isDisconnecting,
isIndexing = false,
searchSpaceId,
- onOtherMCPConnectorsLoaded,
onStartDateChange,
onEndDateChange,
onPeriodicEnabledChange,
@@ -202,7 +200,7 @@ export const ConnectorEditView: FC = ({
onConfigChange={onConfigChange}
onNameChange={onNameChange}
searchSpaceId={searchSpaceId}
- onOtherMCPConnectorsLoaded={onOtherMCPConnectorsLoaded}
+
/>
)}
From 69badbceabc2e338c1d0db8a7f371e04e942819d Mon Sep 17 00:00:00 2001
From: Manoj Aggarwal
Date: Fri, 16 Jan 2026 15:29:05 -0800
Subject: [PATCH 15/18] remove redundant code and make modular
---
.../components/mcp-connect-form.tsx | 86 ++++--------------
.../components/mcp-config.tsx | 91 +++++++------------
.../views/mcp-connector-list-view.tsx | 25 ++++-
3 files changed, 75 insertions(+), 127 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx
index 62d017509..92de0c059 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx
@@ -8,9 +8,14 @@ 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 { connectorsApiService } from "@/lib/apis/connectors-api.service";
+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 = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
@@ -18,11 +23,7 @@ export const MCPConnectForm: FC = ({ onSubmit, isSubmitting })
const [jsonError, setJsonError] = useState(null);
const [isTesting, setIsTesting] = useState(false);
const [showDetails, setShowDetails] = useState(false);
- const [testResult, setTestResult] = useState<{
- status: "success" | "error";
- message: string;
- tools: MCPToolDefinition[];
- } | null>(null);
+ const [testResult, setTestResult] = useState(null);
const DEFAULT_CONFIG = JSON.stringify(
{
@@ -38,35 +39,14 @@ export const MCPConnectForm: FC = ({ onSubmit, isSubmitting })
2
);
- const parseConfig = (): MCPServerConfig | null => {
- try {
- const parsed = JSON.parse(configJson);
-
- // Validate that it's an object, not an array
- if (Array.isArray(parsed)) {
- setJsonError("Please provide a single server configuration object, not an array");
- return null;
- }
-
- // Validate required fields
- if (!parsed.command || typeof parsed.command !== "string") {
- setJsonError("'command' field is required and must be a string");
- return null;
- }
-
- const config: MCPServerConfig = {
- command: parsed.command,
- args: parsed.args || [],
- env: parsed.env || {},
- transport: parsed.transport || "stdio",
- };
-
+ const parseConfig = () => {
+ const result = parseMCPConfig(configJson);
+ if (result.error) {
+ setJsonError(result.error);
+ } else {
setJsonError(null);
- return config;
- } catch (error) {
- setJsonError(error instanceof Error ? error.message : "Invalid JSON");
- return null;
}
+ return result.config;
};
const handleConfigChange = (value: string) => {
@@ -90,31 +70,9 @@ export const MCPConnectForm: FC = ({ onSubmit, isSubmitting })
setIsTesting(true);
setTestResult(null);
- try {
- const result = await connectorsApiService.testMCPConnection(serverConfig);
-
- if (result.status === "success") {
- setTestResult({
- status: "success",
- message: `Successfully connected. Found ${result.tools.length} tool${result.tools.length !== 1 ? 's' : ''}.`,
- tools: result.tools,
- });
- } else {
- setTestResult({
- status: "error",
- message: result.message || "Failed to connect",
- tools: [],
- });
- }
- } catch (error) {
- setTestResult({
- status: "error",
- message: error instanceof Error ? error.message : "Failed to connect",
- tools: [],
- });
- } finally {
- setIsTesting(false);
- }
+ const result = await testMCPConnection(serverConfig);
+ setTestResult(result);
+ setIsTesting(false);
};
const handleSubmit = async (e: React.FormEvent) => {
@@ -131,15 +89,7 @@ export const MCPConnectForm: FC = ({ onSubmit, isSubmitting })
}
// Extract server name from config if provided
- let serverName = "MCP Server";
- try {
- const parsed = JSON.parse(configJson);
- if (parsed.name && typeof parsed.name === "string") {
- serverName = parsed.name;
- }
- } catch {
- // Use default name
- }
+ const serverName = extractServerName(configJson);
isSubmittingRef.current = true;
try {
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx
index 4d141d4ea..f6788105e 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx
@@ -8,25 +8,43 @@ 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 { connectorsApiService } from "@/lib/apis/connectors-api.service";
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 = ({ 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 (
+
+
+ Invalid Connector Type
+
+ This component can only be used with MCP connectors.
+
+
+ );
+ }
+
const [name, setName] = useState("");
const [configJson, setConfigJson] = useState("");
const [jsonError, setJsonError] = useState(null);
const [isTesting, setIsTesting] = useState(false);
const [showDetails, setShowDetails] = useState(false);
- const [testResult, setTestResult] = useState<{
- status: "success" | "error";
- message: string;
- tools: MCPToolDefinition[];
- } | null>(null);
+ const [testResult, setTestResult] = useState(null);
// Initialize form from connector config (only on mount)
useEffect(() => {
@@ -55,35 +73,14 @@ export const MCPConfig: FC = ({ connector, onConfigChange, onNam
}
};
- const parseConfig = (): MCPServerConfig | null => {
- try {
- const parsed = JSON.parse(configJson);
-
- // Validate that it's an object, not an array
- if (Array.isArray(parsed)) {
- setJsonError("Please provide a single server configuration object, not an array");
- return null;
- }
-
- // Validate required fields
- if (!parsed.command || typeof parsed.command !== "string") {
- setJsonError("'command' field is required and must be a string");
- return null;
- }
-
- const config: MCPServerConfig = {
- command: parsed.command,
- args: parsed.args || [],
- env: parsed.env || {},
- transport: parsed.transport || "stdio",
- };
-
+ const parseConfig = () => {
+ const result = parseMCPConfig(configJson);
+ if (result.error) {
+ setJsonError(result.error);
+ } else {
setJsonError(null);
- return config;
- } catch (error) {
- setJsonError(error instanceof Error ? error.message : "Invalid JSON");
- return null;
}
+ return result.config;
};
const handleConfigChange = (value: string) => {
@@ -130,31 +127,9 @@ export const MCPConfig: FC = ({ connector, onConfigChange, onNam
setIsTesting(true);
setTestResult(null);
- try {
- const result = await connectorsApiService.testMCPConnection(serverConfig);
-
- if (result.status === "success") {
- setTestResult({
- status: "success",
- message: `Connected successfully! Found ${result.tools.length} tool(s).`,
- tools: result.tools,
- });
- } else {
- setTestResult({
- status: "error",
- message: result.message || "Failed to connect",
- tools: [],
- });
- }
- } catch (error) {
- setTestResult({
- status: "error",
- message: error instanceof Error ? error.message : "Failed to connect",
- tools: [],
- });
- } finally {
- setIsTesting(false);
- }
+ const result = await testMCPConnection(serverConfig);
+ setTestResult(result);
+ setIsTesting(false);
};
return (
diff --git a/surfsense_web/components/assistant-ui/connector-popup/views/mcp-connector-list-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/views/mcp-connector-list-view.tsx
index 2da6352a9..c826e9fc4 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/views/mcp-connector-list-view.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/views/mcp-connector-list-view.tsx
@@ -1,8 +1,10 @@
"use client";
-import { Plus, Server } from "lucide-react";
+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";
@@ -20,6 +22,27 @@ export const MCPConnectorListView: FC = ({
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 (
+
+
+ Invalid Connector Type
+
+ This view can only display MCP connectors. Found {invalidConnectors.length} invalid
+ connector(s).
+
+
+ );
+ }
return (
{/* Header */}
From 18ce599c81372f3b3462f2e0456f724a6b6cab92 Mon Sep 17 00:00:00 2001
From: Manoj Aggarwal
Date: Fri, 16 Jan 2026 16:13:20 -0800
Subject: [PATCH 16/18] fix electric fallback
---
.../components/assistant-ui/connector-popup.tsx | 12 ++++++------
.../hooks/use-connector-dialog.ts | 3 +++
.../tabs/active-connectors-tab.tsx | 5 +++--
.../connector-popup/tabs/all-connectors-tab.tsx | 3 ++-
surfsense_web/hooks/use-connectors-electric.ts | 17 +++++++++++------
5 files changed, 25 insertions(+), 15 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx
index afb762fe5..90113707e 100644
--- a/surfsense_web/components/assistant-ui/connector-popup.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup.tsx
@@ -99,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)
diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
index fd6c63658..1344abfce 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
+++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts
@@ -715,6 +715,9 @@ export const useConnectorDialog = () => {
: `${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);
url.searchParams.delete("modal");
diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx
index 965add11c..1e25d24a0 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx
@@ -240,6 +240,7 @@ export const ActiveConnectorsTab: FC = ({
connector.connector_type,
documentTypeCounts
);
+ const isMCPConnector = connector.connector_type === "MCP_CONNECTOR";
return (
= ({
Syncing
- ) : (
+ ) : !isMCPConnector ? (
{formatDocumentCount(documentCount)}
- )}
+ ) : null}
= ({
isConnecting={isConnecting}
documentCount={documentCount}
accountCount={accountCount}
- lastIndexedAt={mostRecentLastIndexed}
+
isIndexing={isIndexing}
onConnect={() => onConnectOAuth(connector)}
onManage={
@@ -176,6 +176,7 @@ export const AllConnectorsTab: FC = ({
isConnected={isConnected}
isConnecting={isConnecting}
documentCount={documentCount}
+
isIndexing={isIndexing}
onConnect={handleConnect}
onManage={
diff --git a/surfsense_web/hooks/use-connectors-electric.ts b/surfsense_web/hooks/use-connectors-electric.ts
index ccda052b1..94d5062c9 100644
--- a/surfsense_web/hooks/use-connectors-electric.ts
+++ b/surfsense_web/hooks/use-connectors-electric.ts
@@ -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;
}
From f78c2a685e28e60db484fc3879c81c0af73e7047 Mon Sep 17 00:00:00 2001
From: Manoj Aggarwal
Date: Sat, 17 Jan 2026 09:18:46 -0800
Subject: [PATCH 17/18] add error handling to mcp_tool python files
---
.../app/agents/new_chat/tools/mcp_client.py | 104 +++++++++++++-----
.../app/agents/new_chat/tools/mcp_tool.py | 45 ++++++--
2 files changed, 110 insertions(+), 39 deletions(-)
diff --git a/surfsense_backend/app/agents/new_chat/tools/mcp_client.py b/surfsense_backend/app/agents/new_chat/tools/mcp_client.py
index 437f93043..d4dbe2a0c 100644
--- a/surfsense_backend/app/agents/new_chat/tools/mcp_client.py
+++ b/surfsense_backend/app/agents/new_chat/tools/mcp_client.py
@@ -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.
diff --git a/surfsense_backend/app/agents/new_chat/tools/mcp_tool.py b/surfsense_backend/app/agents/new_chat/tools/mcp_tool.py
index 50339fb93..d7c9210af 100644
--- a/surfsense_backend/app/agents/new_chat/tools/mcp_tool.py
+++ b/surfsense_backend/app/agents/new_chat/tools/mcp_tool.py
@@ -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,21 +152,38 @@ async def load_mcp_tools(
tools: list[StructuredTool] = []
for connector in result.scalars():
try:
- # Extract single server config
+ # Early validation: Extract and validate connector config
config = connector.config or {}
server_config = config.get("server_config", {})
- if not server_config:
- logger.warning(f"MCP connector {connector.id} missing server_config, skipping")
+ # 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} (name: '{connector.name}') has invalid or missing server_config, skipping"
+ )
continue
+ # Validate required command field
command = server_config.get("command")
- args = server_config.get("args", [])
- env = server_config.get("env", {})
-
- if not command:
+ if not command or not isinstance(command, str):
logger.warning(
- f"MCP connector {connector.id} missing command, skipping"
+ 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
From f9a83718523c5a0202c95c410ced59eaa5d44259 Mon Sep 17 00:00:00 2001
From: Manoj Aggarwal
Date: Sat, 17 Jan 2026 09:29:34 -0800
Subject: [PATCH 18/18] add some caching and json validation using zod
---
.../components/mcp-connect-form.tsx | 10 ++
.../components/mcp-config.tsx | 23 +--
.../utils/mcp-config-validator.ts | 163 ++++++++++++++++--
3 files changed, 169 insertions(+), 27 deletions(-)
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx
index 92de0c059..a671c91e8 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx
@@ -51,9 +51,19 @@ export const MCPConnectForm: FC = ({ onSubmit, isSubmitting })
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 () => {
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx
index f6788105e..a0868e2f5 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx
@@ -89,23 +89,14 @@ export const MCPConfig: FC = ({ connector, onConfigChange, onNam
setJsonError(null);
}
- // Try to parse and update parent if valid
- try {
- const parsed = JSON.parse(value);
- if (!Array.isArray(parsed) && parsed.command) {
- const config: MCPServerConfig = {
- command: parsed.command,
- args: parsed.args || [],
- env: parsed.env || {},
- transport: parsed.transport || "stdio",
- };
- if (onConfigChange) {
- onConfigChange({ server_config: config });
- }
- }
- } catch {
- // Ignore parse errors while typing
+ // 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 () => {
diff --git a/surfsense_web/components/assistant-ui/connector-popup/utils/mcp-config-validator.ts b/surfsense_web/components/assistant-ui/connector-popup/utils/mcp-config-validator.ts
index 5c636bdbe..dea547be7 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/utils/mcp-config-validator.ts
+++ b/surfsense_web/components/assistant-ui/connector-popup/utils/mcp-config-validator.ts
@@ -1,6 +1,54 @@
+/**
+ * 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
*/
@@ -18,46 +66,112 @@ export interface MCPConnectionTestResult {
tools: MCPToolDefinition[];
}
+/**
+ * Cache for parsed configurations to avoid re-parsing
+ * Key: JSON string, Value: { config, timestamp }
+ */
+const configCache = new Map();
+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",
};
}
- // Validate required fields
- if (!parsed.command || typeof parsed.command !== "string") {
+ // 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: "'command' field is required and must be a string",
+ error: formattedError,
};
}
const config: MCPServerConfig = {
- command: parsed.command,
- args: parsed.args || [],
- env: parsed.env || {},
- transport: parsed.transport || "stdio",
+ 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: error instanceof Error ? error.message : "Invalid JSON",
+ error: errorMsg,
};
}
};
@@ -96,18 +210,45 @@ export const testMCPConnection = async (
};
/**
- * Extract server name from MCP config JSON
+ * 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);
- if (parsed.name && typeof parsed.name === "string") {
- return parsed.name;
+
+ // 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,
+ })),
+ };
+};