Skip to main content
Glama
iris-config.ts14.9 kB
/** * Iris MCP - Teams Configuration Loader * Loads and validates config.yaml configuration with Zod */ import { readFileSync, existsSync, watchFile } from "fs"; import { resolve, dirname, isAbsolute } from "path"; import { z } from "zod"; import { parse as parseYaml } from "yaml"; import type { TeamsConfig } from "../process-pool/types.js"; import { getChildLogger } from "../utils/logger.js"; import { ConfigurationError } from "../utils/errors.js"; import { getConfigPath, ensureIrisHome } from "../utils/paths.js"; import { interpolateObject } from "../utils/env-interpolation.js"; // Lazy logger getter to avoid initialization at module load time let _logger: ReturnType<typeof getChildLogger> | null = null; const getLogger = () => { if (!_logger) { _logger = getChildLogger("config:teams"); } return _logger; }; // Zod schema for validation const IrisConfigSchema = z .object({ path: z.string().min(1, "Path cannot be empty"), description: z.string(), idleTimeout: z.number().positive().optional(), sessionInitTimeout: z.number().positive().optional(), color: z .string() .regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color") .optional(), // Phase 2: Remote execution via SSH remote: z.string().optional(), // SSH connection string (e.g., "user@host") ssh2: z.boolean().optional(), // Use ssh2 library instead of OpenSSH client (default: false) remoteOptions: z .object({ identity: z.string().optional(), // Path to SSH private key passphrase: z.string().optional(), // Passphrase for encrypted SSH key (ssh2 only) port: z.number().int().min(1).max(65535).optional(), // SSH port strictHostKeyChecking: z.boolean().optional(), // SSH host key checking connectTimeout: z.number().positive().optional(), // Connection timeout in ms serverAliveInterval: z.number().positive().optional(), // Keep-alive interval in seconds serverAliveCountMax: z.number().int().positive().optional(), // Max missed keep-alives compression: z.boolean().optional(), // Enable SSH compression forwardAgent: z.boolean().optional(), // Forward SSH agent extraSshArgs: z.array(z.string()).optional(), // Additional SSH arguments }) .optional(), claudePath: z.string().optional(), // Custom path to Claude CLI executable (default: "claude", supports ~ expansion) // Reverse MCP tunneling enableReverseMcp: z.boolean().optional(), // Enable reverse MCP tunnel for this team reverseMcpPort: z.number().int().min(1).max(65535).optional(), // Port to tunnel (default: 1615) allowHttp: z.boolean().optional(), // Allow HTTP for reverse MCP (dev only, default: false) // Permission approval mode grantPermission: z .enum(["yes", "no", "ask", "forward"]) .optional() .default("ask"), // How to handle permission requests (default: ask for safety) // Tool allowlist/denylist allowedTools: z.string().optional(), // Comma-separated list of allowed MCP tools (passed to Claude CLI --allowed-tools flag) disallowedTools: z.string().optional(), // Comma-separated list of denied MCP tools (passed to Claude CLI --disallowed-tools flag) // System prompt customization appendSystemPrompt: z.string().optional(), // Additional system prompt to append (passed to Claude CLI --append-system-prompt flag) // Session MCP configuration sessionMcpEnabled: z.boolean().optional(), // Enable MCP config file writing for this team (overrides global setting) sessionMcpPath: z.string().optional(), // MCP config directory path relative to team path (overrides global setting) }) .refine( (data) => { // If remote is specified, claudePath is required if (data.remote && !data.claudePath) { return false; } return true; }, { message: "claudePath is required when remote is specified", path: ["claudePath"], }, ); const TeamsConfigSchema = z.object({ settings: z.object({ sessionInitTimeout: z.number().positive().optional().default(30000), spawnTimeout: z.number().positive().optional().default(20000), responseTimeout: z.number().positive().optional().default(120000), idleTimeout: z.number().positive().optional().default(3600000), maxProcesses: z.number().int().min(1).optional().default(10), healthCheckInterval: z.number().positive().optional().default(30000), httpPort: z.number().int().min(1).max(65535).optional().default(1615), defaultTransport: z.enum(["stdio", "http"]).optional().default("stdio"), wonderLoggerConfig: z.string().optional().default("./wonder-logger.yaml"), hotReloadConfig: z.boolean().optional().default(false), sessionMcpEnabled: z.boolean().optional().default(false), sessionMcpPath: z.string().optional().default(".claude/iris/mcp"), }), dashboard: z .object({ enabled: z.boolean().default(true), host: z.string().default("localhost"), http: z.number().int().min(0).max(65535).optional().default(0), https: z.number().int().min(0).max(65535).optional().default(3100), selfsigned: z.boolean().optional().default(false), certPath: z.string().optional(), keyPath: z.string().optional(), }) .refine( (data) => { // At least one of http or https must be enabled (non-zero) if (data.http === 0 && data.https === 0) { return false; } return true; }, { message: "At least one of http or https must be enabled (non-zero port)", }, ) .refine( (data) => { // If https is enabled, must have either selfsigned=true OR both certPath and keyPath if (data.https !== 0) { if (!data.selfsigned && (!data.certPath || !data.keyPath)) { return false; } } return true; }, { message: "HTTPS requires either selfsigned=true or both certPath and keyPath", }, ) .optional() .default({ enabled: true, host: "localhost", http: 3100, https: 0, selfsigned: false, }), database: z .object({ path: z.string().optional().default("data/team-sessions.db"), inMemory: z.boolean().optional().default(false), }) .optional() .default({ path: "data/team-sessions.db", inMemory: false, }), teams: z.record(z.string(), IrisConfigSchema), }); export class TeamsConfigManager { private config: TeamsConfig | null = null; private configPath: string; private watchCallback?: (config: TeamsConfig) => void; constructor(configPath?: string) { // Use provided path, or IRIS_CONFIG_PATH env var, or default to $IRIS_HOME/config.yaml (or ~/.iris/config.yaml) if (configPath) { this.configPath = configPath; } else if (process.env.IRIS_CONFIG_PATH) { this.configPath = resolve(process.env.IRIS_CONFIG_PATH); } else { // Use $IRIS_HOME/config.yaml or ~/.iris/config.yaml this.configPath = getConfigPath(); } // Ensure IRIS_HOME directory structure exists ensureIrisHome(); } /** * Detect spawn script in IRIS_HOME/scripts directory * The spawn script handles both fork and spawn operations */ private detectSpawnScript(): string | undefined { const configDir = dirname(resolve(this.configPath)); const scriptsDir = resolve(configDir, "scripts"); // Platform-specific script names const scriptNames = process.platform === "win32" ? ["spawn.ps1"] : ["spawn.sh"]; for (const scriptName of scriptNames) { const scriptPath = resolve(scriptsDir, scriptName); if (existsSync(scriptPath)) { getLogger().info({ scriptPath }, "Spawn script detected"); return scriptPath; } } getLogger().debug("No spawn script found - Spawn/Fork feature will be disabled"); return undefined; } /** * Load configuration from file */ load(): TeamsConfig { try { // Check if config file exists if (!existsSync(this.configPath)) { console.error( "\n╔════════════════════════════════════════════════════════════════════╗", ); console.error( "║ Iris MCP - Configuration Not Found ║", ); console.error( "╚════════════════════════════════════════════════════════════════════╝\n", ); console.error(`Configuration file not found: ${this.configPath}\n`); console.error( "Run the install command to create the default configuration:\n", ); console.error(" $ iris install\n"); console.error("This will:"); console.error(" 1. Create the Iris MCP configuration file"); console.error(" 2. Install Iris to your Claude CLI configuration\n"); process.exit(0); } const content = readFileSync(this.configPath, "utf8"); // Parse YAML const parsed = parseYaml(content); // Interpolate environment variables const interpolated = interpolateObject(parsed, true); // Validate with Zod const validated = TeamsConfigSchema.parse(interpolated); // Check if teams are configured if (Object.keys(validated.teams).length === 0) { console.error( "\n╔════════════════════════════════════════════════════════════════════╗", ); console.error( "║ Iris MCP - No Teams Configured ║", ); console.error( "╚════════════════════════════════════════════════════════════════════╝\n", ); console.error(`Configuration file: ${this.configPath}\n`); console.error("No teams are configured. Add teams to get started:\n"); console.error("Add a team using current directory:"); console.error(" $ iris add-team <name>\n"); console.error("Add a team with specific path:"); console.error(" $ iris add-team <name> /path/to/project\n"); console.error("Show add-team help:"); console.error(" $ iris add-team --help\n"); process.exit(0); } // Resolve team paths relative to config file directory const configDir = dirname(resolve(this.configPath)); for (const [name, team] of Object.entries(validated.teams)) { // If path is relative, resolve it relative to config file directory if (!isAbsolute(team.path)) { team.path = resolve(configDir, team.path); } // Skip path validation for remote teams - paths exist on remote host if (team.remote) { getLogger().debug( { name, path: team.path, remote: team.remote }, `Skipping path validation for remote team`, ); continue; } // Validate local team paths exist if (!existsSync(team.path)) { getLogger().warn( { name, path: team.path }, `Team "${name}" path does not exist`, ); } } // Cast to TeamsConfig and detect spawn script for Spawn/Fork feature const config: TeamsConfig = validated as TeamsConfig; const spawnScriptPath = this.detectSpawnScript(); if (config.dashboard && spawnScriptPath) { config.dashboard.spawnScriptPath = spawnScriptPath; } this.config = config; getLogger().info( { teams: Object.keys(validated.teams), maxProcesses: validated.settings.maxProcesses, }, "Configuration loaded successfully", ); return this.config; } catch (error) { if (error instanceof z.ZodError) { const messages = error.errors.map( (e) => `${e.path.join(".")}: ${e.message}`, ); throw new ConfigurationError( `Configuration validation failed:\n${messages.join("\n")}`, ); } // YAML parse errors if (error instanceof Error && error.name === "YAMLParseError") { throw new ConfigurationError( `Invalid YAML in configuration file: ${error.message}`, ); } // Environment variable interpolation errors if ( error instanceof Error && error.message.includes("Environment variable") ) { throw new ConfigurationError(error.message); } throw error; } } /** * Get current configuration (throws if not loaded) */ getConfig(): TeamsConfig { if (!this.config) { throw new ConfigurationError( "Configuration not loaded. Call load() first.", ); } return this.config; } /** * Get configuration for a specific team */ getIrisConfig(teamName: string) { const config = this.getConfig(); const team = config.teams[teamName]; if (!team) { return null; } return { ...team, idleTimeout: team.idleTimeout || config.settings.idleTimeout, sessionMcpEnabled: team.sessionMcpEnabled ?? config.settings.sessionMcpEnabled, sessionMcpPath: team.sessionMcpPath || config.settings.sessionMcpPath, }; } /** * Get list of all team names */ getTeamNames(): string[] { return Object.keys(this.getConfig().teams); } /** * Watch configuration file for changes */ watch(callback: (config: TeamsConfig) => void): void { this.watchCallback = callback; watchFile(this.configPath, { interval: 1000 }, () => { getLogger().info("Configuration file changed, reloading..."); try { const newConfig = this.load(); if (this.watchCallback) { this.watchCallback(newConfig); } } catch (error) { getLogger().error( { err: error instanceof Error ? error : new Error(String(error)), }, "Failed to reload configuration", ); } }); getLogger().info("Watching configuration file for changes"); } } // Singleton instance let configManager: TeamsConfigManager | null = null; export function getConfigManager(configPath?: string): TeamsConfigManager { if (!configManager) { configManager = new TeamsConfigManager(configPath); } return configManager; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jenova-marie/iris-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server