Skip to main content
Glama
wpnav-config.ts22.9 kB
/** * WP Navigator Config File Schema and Loader * * Phase B1: wpnav.config.json schema with directory walk-up discovery, * environment variable substitution, and multi-environment support. * * @package WP_Navigator_MCP * @since 1.1.0 */ import * as fs from 'fs'; import * as path from 'path'; // ============================================================================= // Schema Types // ============================================================================= /** * Config file schema version for future migrations */ export const CONFIG_SCHEMA_VERSION = '1.0'; /** * Safety configuration for write operations */ export interface SafetyConfig { /** Enable write operations (default: false) */ enable_writes?: boolean; /** Allow insecure HTTP for localhost development (default: false) */ allow_insecure_http?: boolean; /** Per-tool timeout in milliseconds (default: 600000 = 10 min) */ tool_timeout_ms?: number; /** Maximum response size in KB (default: 64) */ max_response_kb?: number; /** Enable HMAC request signing (default: false) */ sign_headers?: boolean; /** HMAC secret for request signing (required if sign_headers is true) */ hmac_secret?: string; /** Custom CA bundle path for TLS verification */ ca_bundle?: string; } /** * Feature flags for experimental/gated features */ export interface FeaturesConfig { /** Enable AI workflows (default: false) */ workflows?: boolean; /** Enable bulk content validator (default: false) */ bulk_validator?: boolean; /** Enable SEO audit tool (default: false) */ seo_audit?: boolean; /** Enable content reviewer (default: false) */ content_reviewer?: boolean; /** Enable migration planner (default: false) */ migration_planner?: boolean; /** Enable performance analyzer (default: false) */ performance_analyzer?: boolean; } /** * Detected plugin information (auto-populated during connection test) */ export interface DetectedPluginInfo { /** Plugin edition: "free" or "pro" */ edition: 'free' | 'pro'; /** Plugin version string */ version: string; /** When plugin was last detected (ISO timestamp) */ detected_at: string; } /** * Single environment configuration */ export interface EnvironmentConfig { /** WordPress site base URL */ site: string; /** WordPress REST API base URL (defaults to {site}/wp-json) */ rest_api?: string; /** WP Navigator plugin API base URL (defaults to {site}/wp-json/wpnav/v1) */ wpnav_base?: string; /** WordPress application password username */ user: string; /** WordPress application password (supports $ENV_VAR syntax) */ password: string; /** Safety settings for this environment */ safety?: SafetyConfig; /** Feature flags for this environment */ features?: FeaturesConfig; /** Auto-detected plugin information (populated by configure/init commands) */ detected_plugin?: DetectedPluginInfo; } /** * Root wpnav.config.json schema */ export interface WPNavConfigFile { /** Schema version for migration support */ config_version: string; /** Default environment name (default: 'default') */ default_environment?: string; /** Named environments (local, staging, production, etc.) */ environments: Record<string, EnvironmentConfig>; /** Global safety settings (can be overridden per-environment) */ safety?: SafetyConfig; /** Global feature flags (can be overridden per-environment) */ features?: FeaturesConfig; } /** * Resolved configuration after environment selection and defaults */ export interface ResolvedConfig { /** Which environment was resolved */ environment: string; /** Path to the config file that was loaded */ config_path: string; /** WordPress site base URL */ site: string; /** WordPress REST API base URL */ rest_api: string; /** WP Navigator plugin API base URL */ wpnav_base: string; /** WP Navigator introspect endpoint */ wpnav_introspect: string; /** Resolved username */ user: string; /** Resolved password (after env var substitution) */ password: string; /** Merged safety settings */ safety: Required<SafetyConfig>; /** Merged feature flags */ features: Required<FeaturesConfig>; /** Detected plugin information (may be undefined if not yet detected) */ detected_plugin?: DetectedPluginInfo; } // ============================================================================= // Config Discovery // ============================================================================= /** Config file names to search for (in priority order) */ const CONFIG_FILE_NAMES = ['wpnav.config.json', '.wpnav.config.json']; /** Maximum directory levels to search upward */ const MAX_WALK_DEPTH = 10; /** * Result of config file discovery */ export interface ConfigDiscoveryResult { /** Whether a config file was found */ found: boolean; /** Absolute path to the config file (if found) */ path?: string; /** Directories searched (for debugging) */ searched: string[]; } /** * Search for wpnav.config.json walking up the directory tree * Similar to how .eslintrc or .gitignore discovery works * * @param startDir - Directory to start searching from (defaults to cwd) * @returns Discovery result with path if found */ export function discoverConfigFile(startDir?: string): ConfigDiscoveryResult { const searchDir = startDir ? path.resolve(startDir) : process.cwd(); const searched: string[] = []; let currentDir = searchDir; let depth = 0; while (depth < MAX_WALK_DEPTH) { searched.push(currentDir); for (const fileName of CONFIG_FILE_NAMES) { const configPath = path.join(currentDir, fileName); if (fs.existsSync(configPath)) { return { found: true, path: configPath, searched, }; } } // Move to parent directory const parentDir = path.dirname(currentDir); // Stop if we've reached the root if (parentDir === currentDir) { break; } currentDir = parentDir; depth++; } return { found: false, searched, }; } // ============================================================================= // Environment Variable Resolution // ============================================================================= /** * Pattern for ${VAR_NAME} syntax (explicit boundaries) */ const ENV_VAR_BRACED_PATTERN = /\$\{([A-Z_][A-Z0-9_]*)\}/g; /** * Pattern for $VAR_NAME syntax (simple, terminated by non-alphanumeric/non-underscore) * Uses word boundary to stop at non-identifier characters */ const ENV_VAR_SIMPLE_PATTERN = /\$([A-Z_][A-Z0-9_]*)(?![A-Z0-9_])/g; /** * Resolve environment variable references in a string value * * Supports two syntaxes: * - $VAR_NAME (simple, terminated by non-identifier char) * - ${VAR_NAME} (explicit boundaries) * * @param value - String potentially containing env var references * @returns Resolved string with env vars substituted * @throws Error if referenced env var is not set */ export function resolveEnvVars(value: string): string { // First resolve ${VAR} syntax (more specific) let result = value.replace(ENV_VAR_BRACED_PATTERN, (match, varName) => { const envValue = process.env[varName]; if (envValue === undefined) { throw new Error(`Environment variable ${varName} is not set (referenced as ${match})`); } return envValue; }); // Then resolve $VAR syntax result = result.replace(ENV_VAR_SIMPLE_PATTERN, (match, varName) => { const envValue = process.env[varName]; if (envValue === undefined) { throw new Error(`Environment variable ${varName} is not set (referenced as ${match})`); } return envValue; }); return result; } /** * Check if a string contains environment variable references * Uses fresh regex instances to avoid stateful lastIndex issues */ export function containsEnvVars(value: string): boolean { const bracedPattern = /\$\{([A-Z_][A-Z0-9_]*)\}/; const simplePattern = /\$([A-Z_][A-Z0-9_]*)(?![A-Z0-9_])/; return bracedPattern.test(value) || simplePattern.test(value); } // ============================================================================= // Config Loading and Validation // ============================================================================= /** * Error thrown when config validation fails */ export class ConfigValidationError extends Error { constructor( message: string, public readonly path?: string, public readonly field?: string ) { super(message); this.name = 'ConfigValidationError'; } } /** * Validate config file structure */ function validateConfigFile(config: unknown, filePath: string): WPNavConfigFile { if (!config || typeof config !== 'object') { throw new ConfigValidationError('Config file must be a JSON object', filePath); } const obj = config as Record<string, unknown>; // Validate config_version if (!obj.config_version || typeof obj.config_version !== 'string') { throw new ConfigValidationError( 'Missing or invalid config_version (expected string like "1.0")', filePath, 'config_version' ); } // Validate environments if (!obj.environments || typeof obj.environments !== 'object') { throw new ConfigValidationError( 'Missing or invalid environments object', filePath, 'environments' ); } const envs = obj.environments as Record<string, unknown>; if (Object.keys(envs).length === 0) { throw new ConfigValidationError( 'At least one environment must be defined', filePath, 'environments' ); } // Validate each environment for (const [envName, envConfig] of Object.entries(envs)) { validateEnvironmentConfig(envConfig, filePath, envName); } return config as WPNavConfigFile; } /** * Validate a single environment configuration */ function validateEnvironmentConfig( config: unknown, filePath: string, envName: string ): void { if (!config || typeof config !== 'object') { throw new ConfigValidationError( `Environment '${envName}' must be an object`, filePath, `environments.${envName}` ); } const env = config as Record<string, unknown>; // Required fields if (!env.site || typeof env.site !== 'string') { throw new ConfigValidationError( `Environment '${envName}' missing required 'site' URL`, filePath, `environments.${envName}.site` ); } if (!env.user || typeof env.user !== 'string') { throw new ConfigValidationError( `Environment '${envName}' missing required 'user' field`, filePath, `environments.${envName}.user` ); } if (!env.password || typeof env.password !== 'string') { throw new ConfigValidationError( `Environment '${envName}' missing required 'password' field`, filePath, `environments.${envName}.password` ); } } /** * Parse and validate a config file * * @param filePath - Path to the config file * @returns Validated config file object */ export function parseConfigFile(filePath: string): WPNavConfigFile { if (!fs.existsSync(filePath)) { throw new ConfigValidationError(`Config file not found: ${filePath}`, filePath); } let content: string; try { content = fs.readFileSync(filePath, 'utf8'); } catch (error) { throw new ConfigValidationError( `Failed to read config file: ${error instanceof Error ? error.message : String(error)}`, filePath ); } let parsed: unknown; try { parsed = JSON.parse(content); } catch (error) { throw new ConfigValidationError( `Invalid JSON in config file: ${error instanceof Error ? error.message : String(error)}`, filePath ); } return validateConfigFile(parsed, filePath); } // ============================================================================= // Config Resolution // ============================================================================= /** Default safety settings */ const DEFAULT_SAFETY: Required<SafetyConfig> = { enable_writes: false, allow_insecure_http: false, tool_timeout_ms: 600000, max_response_kb: 64, sign_headers: false, hmac_secret: '', ca_bundle: '', }; /** Default feature flags */ const DEFAULT_FEATURES: Required<FeaturesConfig> = { workflows: false, bulk_validator: false, seo_audit: false, content_reviewer: false, migration_planner: false, performance_analyzer: false, }; /** * Merge safety configs with defaults */ function mergeSafety( global?: SafetyConfig, env?: SafetyConfig ): Required<SafetyConfig> { return { ...DEFAULT_SAFETY, ...global, ...env, }; } /** * Merge feature configs with defaults */ function mergeFeatures( global?: FeaturesConfig, env?: FeaturesConfig ): Required<FeaturesConfig> { return { ...DEFAULT_FEATURES, ...global, ...env, }; } /** * Resolve a config file to a specific environment * * @param config - Parsed config file * @param configPath - Path to the config file * @param environment - Environment to resolve (defaults to default_environment or 'default') * @returns Fully resolved configuration */ export function resolveConfig( config: WPNavConfigFile, configPath: string, environment?: string ): ResolvedConfig { // Determine which environment to use const envName = environment ?? config.default_environment ?? 'default'; const envConfig = config.environments[envName]; if (!envConfig) { const available = Object.keys(config.environments).join(', '); throw new ConfigValidationError( `Environment '${envName}' not found. Available: ${available}`, configPath, 'environments' ); } // Resolve env vars in password let password: string; try { password = resolveEnvVars(envConfig.password); } catch (error) { throw new ConfigValidationError( `Failed to resolve password for environment '${envName}': ${error instanceof Error ? error.message : String(error)}`, configPath, `environments.${envName}.password` ); } // Build URLs with defaults const site = envConfig.site.replace(/\/$/, ''); // Remove trailing slash const restApi = envConfig.rest_api ?? `${site}/wp-json`; const wpnavBase = envConfig.wpnav_base ?? `${site}/wp-json/wpnav/v1`; const wpnavIntrospect = `${wpnavBase}/introspect`; // Merge safety and features (env overrides global) const safety = mergeSafety(config.safety, envConfig.safety); const features = mergeFeatures(config.features, envConfig.features); // Resolve env vars in hmac_secret if present if (safety.hmac_secret && containsEnvVars(safety.hmac_secret)) { try { safety.hmac_secret = resolveEnvVars(safety.hmac_secret); } catch (error) { throw new ConfigValidationError( `Failed to resolve hmac_secret: ${error instanceof Error ? error.message : String(error)}`, configPath, 'safety.hmac_secret' ); } } // Validate: if signing is enabled, secret is required if (safety.sign_headers && !safety.hmac_secret) { throw new ConfigValidationError( 'sign_headers is enabled but hmac_secret is not set', configPath, 'safety.hmac_secret' ); } return { environment: envName, config_path: configPath, site, rest_api: restApi, wpnav_base: wpnavBase, wpnav_introspect: wpnavIntrospect, user: envConfig.user, password, safety, features, detected_plugin: envConfig.detected_plugin, }; } // ============================================================================= // High-Level Loading API // ============================================================================= /** * Options for loading config */ export interface LoadConfigOptions { /** Explicit path to config file (skips discovery) */ configPath?: string; /** Directory to start discovery from (defaults to cwd) */ startDir?: string; /** Environment to resolve */ environment?: string; /** Whether to fall back to env vars if no config file found */ fallbackToEnv?: boolean; } /** * Result of config loading */ export interface LoadConfigResult { /** Whether config was loaded successfully */ success: boolean; /** Resolved configuration (if successful) */ config?: ResolvedConfig; /** Source of configuration ('file' or 'env') */ source?: 'file' | 'env'; /** Error message (if failed) */ error?: string; /** Detailed error information */ errorDetails?: { path?: string; field?: string; searched?: string[]; }; } /** * Load config from environment variables (fallback mode) */ function loadFromEnvVars(): ResolvedConfig | null { const required = [ 'WP_BASE_URL', 'WP_REST_API', 'WPNAV_BASE', 'WPNAV_INTROSPECT', 'WP_APP_USER', 'WP_APP_PASS', ]; const missing = required.filter((k) => !process.env[k]); if (missing.length > 0) { return null; } return { environment: process.env.WPNAV_ENVIRONMENT ?? 'default', config_path: '[environment variables]', site: process.env.WP_BASE_URL!, rest_api: process.env.WP_REST_API!, wpnav_base: process.env.WPNAV_BASE!, wpnav_introspect: process.env.WPNAV_INTROSPECT!, user: process.env.WP_APP_USER!, password: process.env.WP_APP_PASS!, safety: { enable_writes: readBool(process.env.WPNAV_ENABLE_WRITES, false), allow_insecure_http: readBool(process.env.ALLOW_INSECURE_HTTP, false), tool_timeout_ms: readInt(process.env.WPNAV_TOOL_TIMEOUT_MS, 600000), max_response_kb: readInt(process.env.WPNAV_MAX_RESPONSE_KB, 64), sign_headers: readBool(process.env.WPNAV_SIGN_HEADERS, false), hmac_secret: process.env.WPNAV_HMAC_SECRET ?? '', ca_bundle: process.env.WPNAV_CA_BUNDLE ?? '', }, features: { workflows: readBool(process.env.WPNAV_FLAG_WORKFLOWS_ENABLED, false), bulk_validator: readBool(process.env.WPNAV_FLAG_WP_BULK_VALIDATOR_ENABLED, false), seo_audit: readBool(process.env.WPNAV_FLAG_WP_SEO_AUDIT_ENABLED, false), content_reviewer: readBool(process.env.WPNAV_FLAG_WP_CONTENT_REVIEWER_ENABLED, false), migration_planner: readBool(process.env.WPNAV_FLAG_WP_MIGRATION_PLANNER_ENABLED, false), performance_analyzer: readBool(process.env.WPNAV_FLAG_WP_PERFORMANCE_ANALYZER_ENABLED, false), }, }; } function readBool(v: string | undefined, defaultVal: boolean): boolean { if (v == null) return defaultVal; const s = String(v).trim().toLowerCase(); return s === '1' || s === 'true' || s === 'yes' || s === 'on'; } function readInt(v: string | undefined, defaultVal: number): number { const n = v != null ? parseInt(String(v), 10) : NaN; return Number.isFinite(n) ? n : defaultVal; } /** * Load wpnav.config.json with directory walk-up discovery * * @param options - Loading options * @returns Loading result with config or error */ export function loadWpnavConfig(options: LoadConfigOptions = {}): LoadConfigResult { const { configPath, startDir, environment, fallbackToEnv = true } = options; // If explicit path provided, use it directly if (configPath) { try { const config = parseConfigFile(configPath); const resolved = resolveConfig(config, configPath, environment); return { success: true, config: resolved, source: 'file', }; } catch (error) { if (error instanceof ConfigValidationError) { return { success: false, error: error.message, errorDetails: { path: error.path, field: error.field, }, }; } return { success: false, error: error instanceof Error ? error.message : String(error), }; } } // Discover config file const discovery = discoverConfigFile(startDir); if (discovery.found && discovery.path) { try { const config = parseConfigFile(discovery.path); const resolved = resolveConfig(config, discovery.path, environment); return { success: true, config: resolved, source: 'file', }; } catch (error) { if (error instanceof ConfigValidationError) { return { success: false, error: error.message, errorDetails: { path: error.path, field: error.field, searched: discovery.searched, }, }; } return { success: false, error: error instanceof Error ? error.message : String(error), errorDetails: { searched: discovery.searched, }, }; } } // No config file found - try env vars fallback if (fallbackToEnv) { const envConfig = loadFromEnvVars(); if (envConfig) { return { success: true, config: envConfig, source: 'env', }; } } return { success: false, error: 'No wpnav.config.json found and required environment variables are not set', errorDetails: { searched: discovery.searched, }, }; } /** * Convert ResolvedConfig to the legacy WPConfig format for compatibility * with existing code (http.ts, tools, etc.) */ export function toLegacyConfig(resolved: ResolvedConfig): { baseUrl: string; restApi: string; wpnavBase: string; wpnavIntrospect: string; auth: { username: string; password: string; signHeaders?: boolean; hmacSecret?: string; }; toggles: { enableWrites: boolean; allowInsecureHttp: boolean; toolTimeoutMs: number; maxResponseKb: number; caBundlePath?: string; }; featureFlags: { workflowsEnabled: boolean; bulkValidatorEnabled: boolean; seoAuditEnabled: boolean; contentReviewerEnabled: boolean; migrationPlannerEnabled: boolean; performanceAnalyzerEnabled: boolean; }; } { return { baseUrl: resolved.site, restApi: resolved.rest_api, wpnavBase: resolved.wpnav_base, wpnavIntrospect: resolved.wpnav_introspect, auth: { username: resolved.user, password: resolved.password, signHeaders: resolved.safety.sign_headers || undefined, hmacSecret: resolved.safety.hmac_secret || undefined, }, toggles: { enableWrites: resolved.safety.enable_writes, allowInsecureHttp: resolved.safety.allow_insecure_http, toolTimeoutMs: resolved.safety.tool_timeout_ms, maxResponseKb: resolved.safety.max_response_kb, caBundlePath: resolved.safety.ca_bundle || undefined, }, featureFlags: { workflowsEnabled: resolved.features.workflows, bulkValidatorEnabled: resolved.features.bulk_validator, seoAuditEnabled: resolved.features.seo_audit, contentReviewerEnabled: resolved.features.content_reviewer, migrationPlannerEnabled: resolved.features.migration_planner, performanceAnalyzerEnabled: resolved.features.performance_analyzer, }, }; }

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/littlebearapps/wp-navigator-mcp'

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