From f9a83718523c5a0202c95c410ced59eaa5d44259 Mon Sep 17 00:00:00 2001 From: Manoj Aggarwal Date: Sat, 17 Jan 2026 09:29:34 -0800 Subject: [PATCH] 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, + })), + }; +};