add some caching and json validation using zod

This commit is contained in:
Manoj Aggarwal 2026-01-17 09:29:34 -08:00
parent f78c2a685e
commit f9a8371852
3 changed files with 169 additions and 27 deletions

View file

@ -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<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",
};
}
// 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,
})),
};
};