/**
* @fileoverview Loads, validates, and exports application configuration.
* This module centralizes configuration management, sourcing values from
* environment variables and `package.json`. It uses Zod for schema validation
* to ensure type safety and correctness of configuration parameters.
*
* @module src/config/index
*/
import dotenv from "dotenv";
import { existsSync, mkdirSync, readFileSync, statSync } from "fs";
import { homedir } from "os";
import path, { dirname, join } from "path";
import { fileURLToPath } from "url";
import { z } from "zod";
// Load .env from multiple possible locations for monorepo flexibility
// Priority order:
// 1. MCP_SERVER_CONFIG environment variable (explicit override)
// 2. Current working directory (for production/user deployment)
// 3. Parent directory (for monorepo development)
let envLoaded = false;
// Check for explicit config path via MCP_SERVER_CONFIG
if (process.env.MCP_SERVER_CONFIG) {
const configPath = path.resolve(process.env.MCP_SERVER_CONFIG);
if (existsSync(configPath)) {
dotenv.config({ path: configPath });
envLoaded = true;
if (process.stdout.isTTY && process.env.MCP_LOG_LEVEL === "debug") {
console.error(`Loaded .env from MCP_SERVER_CONFIG: ${configPath}`);
}
} else {
if (process.stdout.isTTY) {
console.error(
`Warning: MCP_SERVER_CONFIG is set to "${configPath}" but file does not exist. Falling back to default search.`,
);
}
}
}
// If no explicit config or it failed, try default locations
if (!envLoaded) {
const envLocations = [
// 1. Current working directory (for production/user deployment)
path.resolve(process.cwd(), ".env"),
// 2. Parent directory (for monorepo development)
path.resolve(process.cwd(), "../.env"),
];
// Load from first location that exists
for (const envPath of envLocations) {
if (existsSync(envPath)) {
dotenv.config({ path: envPath });
envLoaded = true;
if (process.stdout.isTTY && process.env.MCP_LOG_LEVEL === "debug") {
console.error(`Loaded .env from: ${envPath}`);
}
break;
}
}
// Fallback: try default dotenv behavior (looks in cwd)
if (!envLoaded) {
dotenv.config();
}
}
// --- Determine Project Root ---
const findProjectRoot = (startDir: string): string => {
let currentDir = startDir;
// If the start directory is in `dist`, start searching from the parent directory.
if (path.basename(currentDir) === "dist") {
currentDir = path.dirname(currentDir);
}
while (true) {
const packageJsonPath = join(currentDir, "package.json");
if (existsSync(packageJsonPath)) {
return currentDir;
}
const parentDir = dirname(currentDir);
if (parentDir === currentDir) {
throw new Error(
`Could not find project root (package.json) starting from ${startDir}`,
);
}
currentDir = parentDir;
}
};
let projectRoot: string;
try {
const currentModuleDir = dirname(fileURLToPath(import.meta.url));
projectRoot = findProjectRoot(currentModuleDir);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`FATAL: Error determining project root: ${errorMessage}`);
projectRoot = process.cwd();
if (process.stdout.isTTY) {
console.warn(
`Warning: Using process.cwd() (${projectRoot}) as fallback project root.`,
);
}
}
// --- End Determine Project Root ---
/**
* Loads and parses the package.json file from the project root.
* @returns The parsed package.json object or a fallback default.
* @private
*/
const loadPackageJson = (): { name: string; version: string } => {
const pkgPath = join(projectRoot, "package.json");
const fallback = { name: "ibmi-mcp-server", version: "1.0.0" };
if (!existsSync(pkgPath)) {
if (process.stdout.isTTY) {
console.warn(
`Warning: package.json not found at ${pkgPath}. Using fallback values. This is expected in some environments (e.g., Docker) but may indicate an issue with project root detection.`,
);
}
return fallback;
}
try {
const fileContents = readFileSync(pkgPath, "utf-8");
const parsed = JSON.parse(fileContents);
return {
name: typeof parsed.name === "string" ? parsed.name : fallback.name,
version:
typeof parsed.version === "string" ? parsed.version : fallback.version,
};
} catch (error) {
if (process.stdout.isTTY) {
console.error(
"Warning: Could not read or parse package.json. Using hardcoded defaults.",
error,
);
}
return fallback;
}
};
const pkg = loadPackageJson();
const EnvSchema = z.object({
// --- Existing MCP and other variables ---
MCP_SERVER_NAME: z.string().optional(),
MCP_SERVER_VERSION: z.string().optional(),
MCP_LOG_LEVEL: z.string().default("debug"),
LOGS_DIR: z
.string()
.default(path.join(homedir(), ".ibmi-mcp-server", "logs")),
NODE_ENV: z.string().default("development"),
MCP_TRANSPORT_TYPE: z.enum(["stdio", "http"]).default("stdio"),
MCP_SESSION_MODE: z.enum(["stateless", "stateful", "auto"]).default("auto"),
MCP_HTTP_PORT: z.coerce.number().int().positive().default(3010),
MCP_HTTP_HOST: z.string().default("0.0.0.0"),
MCP_HTTP_ENDPOINT_PATH: z.string().default("/mcp"),
MCP_HTTP_MAX_PORT_RETRIES: z.coerce.number().int().nonnegative().default(15),
MCP_HTTP_PORT_RETRY_DELAY_MS: z.coerce
.number()
.int()
.nonnegative()
.default(50),
MCP_STATEFUL_SESSION_STALE_TIMEOUT_MS: z.coerce
.number()
.int()
.positive()
.default(1_800_000),
MCP_ALLOWED_ORIGINS: z.string().optional(),
MCP_AUTH_SECRET_KEY: z
.string()
.min(
32,
"MCP_AUTH_SECRET_KEY must be at least 32 characters long for security reasons.",
)
.optional(),
MCP_AUTH_MODE: z.enum(["jwt", "oauth", "ibmi", "none"]).default("none"),
OAUTH_ISSUER_URL: z.string().url().optional(),
OAUTH_JWKS_URI: z.string().url().optional(),
OAUTH_AUDIENCE: z.string().optional(),
DEV_MCP_CLIENT_ID: z.string().optional(),
DEV_MCP_SCOPES: z.string().optional(),
OPENROUTER_APP_URL: z
.string()
.url("OPENROUTER_APP_URL must be a valid URL (e.g., http://localhost:3000)")
.optional(),
OPENROUTER_APP_NAME: z.string().optional(),
OPENROUTER_API_KEY: z.string().optional(),
LLM_DEFAULT_MODEL: z.string().default("google/gemini-2.5-flash"),
LLM_DEFAULT_TEMPERATURE: z.coerce.number().min(0).max(2).optional(),
LLM_DEFAULT_TOP_P: z.coerce.number().min(0).max(1).optional(),
LLM_DEFAULT_MAX_TOKENS: z.coerce.number().int().positive().optional(),
LLM_DEFAULT_TOP_K: z.coerce.number().int().nonnegative().optional(),
LLM_DEFAULT_MIN_P: z.coerce.number().min(0).max(1).optional(),
OAUTH_PROXY_AUTHORIZATION_URL: z
.string()
.url("OAUTH_PROXY_AUTHORIZATION_URL must be a valid URL.")
.optional(),
OAUTH_PROXY_TOKEN_URL: z
.string()
.url("OAUTH_PROXY_TOKEN_URL must be a valid URL.")
.optional(),
OAUTH_PROXY_REVOCATION_URL: z
.string()
.url("OAUTH_PROXY_REVOCATION_URL must be a valid URL.")
.optional(),
OAUTH_PROXY_ISSUER_URL: z
.string()
.url("OAUTH_PROXY_ISSUER_URL must be a valid URL.")
.optional(),
OAUTH_PROXY_SERVICE_DOCUMENTATION_URL: z
.string()
.url("OAUTH_PROXY_SERVICE_DOCUMENTATION_URL must be a valid URL.")
.optional(),
OAUTH_PROXY_DEFAULT_CLIENT_REDIRECT_URIS: z.string().optional(),
SUPABASE_URL: z.string().url("SUPABASE_URL must be a valid URL.").optional(),
SUPABASE_ANON_KEY: z.string().optional(),
SUPABASE_SERVICE_ROLE_KEY: z.string().optional(),
// --- START: OpenTelemetry Configuration ---
/** If 'true', OpenTelemetry will be initialized and enabled. Default: 'false'. */
OTEL_ENABLED: z
.string()
.transform((v) => v.toLowerCase() === "true")
.default("false"),
/** The logical name of the service. Defaults to MCP_SERVER_NAME or package name. */
OTEL_SERVICE_NAME: z.string().optional(),
/** The version of the service. Defaults to MCP_SERVER_VERSION or package version. */
OTEL_SERVICE_VERSION: z.string().optional(),
/** The OTLP endpoint for traces. If not set, traces are logged to a file in development. */
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: z.string().url().optional(),
/** The OTLP endpoint for metrics. If not set, metrics are not exported. */
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: z.string().url().optional(),
/** Sampling ratio for traces (0.0 to 1.0). 1.0 means sample all. Default: 1.0 */
OTEL_TRACES_SAMPLER_ARG: z.coerce.number().min(0).max(1).default(1.0),
/** Log level for OpenTelemetry's internal diagnostic logger. Default: "INFO". */
OTEL_LOG_LEVEL: z
.enum(["NONE", "ERROR", "WARN", "INFO", "DEBUG", "VERBOSE", "ALL"])
.default("INFO"),
/** IBM i Mapepire daemon server host. From `DB2i_HOST`. */
DB2i_HOST: z
.string()
.min(1, "DB2i_HOST is required for IBM i connections.")
.optional(),
/** IBM i DB2 user name. From `DB2i_USER`. */
DB2i_USER: z
.string()
.min(1, "DB2i_USER is required for IBM i connections.")
.optional(),
/** IBM i DB2 password. From `DB2i_PASS`. */
DB2i_PASS: z
.string()
.min(1, "DB2i_PASS is required for IBM i connections.")
.optional(),
/** Ignore unauthorized SSL certificates for Mapepire. From `DB2i_IGNORE_UNAUTHORIZED`. Default: true. */
DB2i_IGNORE_UNAUTHORIZED: z
.string()
.optional()
.default("true")
.transform((val) => val === "true" || val === "1"),
/** Path to YAML tools configuration file. From `TOOLS_YAML_PATH`. */
TOOLS_YAML_PATH: z
.string()
.min(1, "TOOLS_YAML_PATH is required for YAML-based tools.")
.optional(),
/** YAML merge options configuration. From `YAML_MERGE_*` environment variables. */
YAML_MERGE_ARRAYS: z
.string()
.optional()
.default("true")
.transform((val) => val === "true"),
YAML_ALLOW_DUPLICATE_TOOLS: z
.string()
.optional()
.default("false")
.transform((val) => val === "true"),
YAML_ALLOW_DUPLICATE_SOURCES: z
.string()
.optional()
.default("false")
.transform((val) => val === "true"),
YAML_VALIDATE_MERGED: z
.string()
.optional()
.default("true")
.transform((val) => val === "true"),
/** IBM i HTTP Authentication configuration. From `IBMI_AUTH_*` environment variables. */
IBMI_HTTP_AUTH_ENABLED: z
.string()
.optional()
.default("false")
.transform((val) => val === "true" || val === "1"),
IBMI_AUTH_ALLOW_HTTP: z
.string()
.optional()
.default("false")
.transform((val) => val === "true" || val === "1"),
IBMI_AUTH_TOKEN_EXPIRY_SECONDS: z.coerce
.number()
.int()
.positive()
.default(3600),
IBMI_AUTH_CLEANUP_INTERVAL_SECONDS: z.coerce
.number()
.int()
.positive()
.default(300),
IBMI_AUTH_MAX_CONCURRENT_SESSIONS: z.coerce
.number()
.int()
.positive()
.default(100),
IBMI_AUTH_PRIVATE_KEY_PATH: z.string().min(1).optional(),
IBMI_AUTH_PUBLIC_KEY_PATH: z.string().min(1).optional(),
IBMI_AUTH_KEY_ID: z.string().min(1).optional(),
/** Enable automatic reloading of YAML tools when configuration files change. From `YAML_AUTO_RELOAD`. Default: true. */
YAML_AUTO_RELOAD: z
.string()
.optional()
.default("true")
.transform((val) => val === "true" || val === "1"),
SELECTED_TOOLSETS: z.string().optional(),
});
const parsedEnv = EnvSchema.safeParse(process.env);
if (!parsedEnv.success) {
if (process.stdout.isTTY) {
console.error(
"❌ Invalid environment variables found:",
parsedEnv.error.flatten().fieldErrors,
);
}
}
const env = parsedEnv.success ? parsedEnv.data : EnvSchema.parse({});
/**
* Expands tilde (~) to user's home directory
* @param filePath - Path that may contain ~
* @returns Path with ~ expanded to home directory
*/
const expandTilde = (filePath: string): string => {
if (filePath.startsWith("~/") || filePath === "~") {
return path.join(homedir(), filePath.slice(1));
}
return filePath;
};
/**
* Ensures a directory exists, creating it if necessary.
* Handles absolute paths, relative paths, and tilde (~) expansion.
*
* @param dirPath - Directory path (absolute, relative, or with ~)
* @param baseDir - Base directory for resolving relative paths (typically process.cwd())
* @param dirName - Human-readable name for logging
* @returns Resolved absolute path to the directory, or null if creation failed
*/
const ensureDirectory = (
dirPath: string,
baseDir: string,
dirName: string,
): string | null => {
// Expand ~ to home directory first
const expandedPath = expandTilde(dirPath);
// Resolve path: absolute paths used as-is, relative paths resolved from baseDir
const resolvedDirPath = path.isAbsolute(expandedPath)
? expandedPath
: path.resolve(baseDir, expandedPath);
// Try to create directory if it doesn't exist
if (!existsSync(resolvedDirPath)) {
try {
mkdirSync(resolvedDirPath, { recursive: true });
if (process.stdout.isTTY) {
console.error(`Created ${dirName} directory: ${resolvedDirPath}`);
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
if (process.stdout.isTTY) {
console.error(
`Error creating ${dirName} directory at ${resolvedDirPath}: ${errorMessage}`,
);
}
return null;
}
} else {
// Verify existing path is actually a directory
try {
const stats = statSync(resolvedDirPath);
if (!stats.isDirectory()) {
if (process.stdout.isTTY) {
console.error(
`Error: ${dirName} path ${resolvedDirPath} exists but is not a directory.`,
);
}
return null;
}
} catch (statError: unknown) {
if (process.stdout.isTTY) {
const statErrorMessage =
statError instanceof Error ? statError.message : String(statError);
console.error(
`Error accessing ${dirName} path ${resolvedDirPath}: ${statErrorMessage}`,
);
}
return null;
}
}
return resolvedDirPath;
};
// Ensure logs directory exists
// Supports multiple path formats:
// - Absolute: /var/log/ibmi-mcp
// - Relative: ./logs (resolved from process.cwd())
// - Tilde: ~/my-logs (expanded to home directory)
const validatedLogsPath: string | null = ensureDirectory(
env.LOGS_DIR,
process.cwd(),
"logs",
);
if (!validatedLogsPath) {
if (process.stdout.isTTY) {
console.warn(
`Warning: Could not create logs directory at '${env.LOGS_DIR}'. File logging will be disabled.`,
);
}
}
export const config = {
pkg,
mcpServerName: env.MCP_SERVER_NAME || pkg.name,
mcpServerVersion: env.MCP_SERVER_VERSION || pkg.version,
logLevel: env.MCP_LOG_LEVEL,
logsPath: validatedLogsPath,
environment: env.NODE_ENV,
mcpTransportType: env.MCP_TRANSPORT_TYPE,
mcpSessionMode: env.MCP_SESSION_MODE,
mcpHttpPort: env.MCP_HTTP_PORT,
mcpHttpHost: env.MCP_HTTP_HOST,
mcpHttpEndpointPath: env.MCP_HTTP_ENDPOINT_PATH,
mcpHttpMaxPortRetries: env.MCP_HTTP_MAX_PORT_RETRIES,
mcpHttpPortRetryDelayMs: env.MCP_HTTP_PORT_RETRY_DELAY_MS,
mcpStatefulSessionStaleTimeoutMs: env.MCP_STATEFUL_SESSION_STALE_TIMEOUT_MS,
mcpAllowedOrigins: env.MCP_ALLOWED_ORIGINS?.split(",")
.map((origin) => origin.trim())
.filter(Boolean),
mcpAuthSecretKey: env.MCP_AUTH_SECRET_KEY,
mcpAuthMode: env.MCP_AUTH_MODE,
oauthIssuerUrl: env.OAUTH_ISSUER_URL,
oauthJwksUri: env.OAUTH_JWKS_URI,
oauthAudience: env.OAUTH_AUDIENCE,
devMcpClientId: env.DEV_MCP_CLIENT_ID,
devMcpScopes: env.DEV_MCP_SCOPES?.split(",").map((s) => s.trim()),
openrouterAppUrl: env.OPENROUTER_APP_URL || "http://localhost:3000",
openrouterAppName: env.OPENROUTER_APP_NAME || pkg.name || "ibmi-mcp-server",
openrouterApiKey: env.OPENROUTER_API_KEY,
llmDefaultModel: env.LLM_DEFAULT_MODEL,
llmDefaultTemperature: env.LLM_DEFAULT_TEMPERATURE,
llmDefaultTopP: env.LLM_DEFAULT_TOP_P,
llmDefaultMaxTokens: env.LLM_DEFAULT_MAX_TOKENS,
llmDefaultTopK: env.LLM_DEFAULT_TOP_K,
llmDefaultMinP: env.LLM_DEFAULT_MIN_P,
oauthProxy:
env.OAUTH_PROXY_AUTHORIZATION_URL ||
env.OAUTH_PROXY_TOKEN_URL ||
env.OAUTH_PROXY_REVOCATION_URL ||
env.OAUTH_PROXY_ISSUER_URL ||
env.OAUTH_PROXY_SERVICE_DOCUMENTATION_URL ||
env.OAUTH_PROXY_DEFAULT_CLIENT_REDIRECT_URIS
? {
authorizationUrl: env.OAUTH_PROXY_AUTHORIZATION_URL,
tokenUrl: env.OAUTH_PROXY_TOKEN_URL,
revocationUrl: env.OAUTH_PROXY_REVOCATION_URL,
issuerUrl: env.OAUTH_PROXY_ISSUER_URL,
serviceDocumentationUrl: env.OAUTH_PROXY_SERVICE_DOCUMENTATION_URL,
defaultClientRedirectUris:
env.OAUTH_PROXY_DEFAULT_CLIENT_REDIRECT_URIS?.split(",")
.map((uri) => uri.trim())
.filter(Boolean),
}
: undefined,
supabase:
env.SUPABASE_URL && env.SUPABASE_ANON_KEY
? {
url: env.SUPABASE_URL,
anonKey: env.SUPABASE_ANON_KEY,
serviceRoleKey: env.SUPABASE_SERVICE_ROLE_KEY,
}
: undefined,
openTelemetry: {
enabled: env.OTEL_ENABLED,
serviceName: env.OTEL_SERVICE_NAME || env.MCP_SERVER_NAME || pkg.name,
serviceVersion:
env.OTEL_SERVICE_VERSION || env.MCP_SERVER_VERSION || pkg.version,
tracesEndpoint: env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT,
metricsEndpoint: env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT,
samplingRatio: env.OTEL_TRACES_SAMPLER_ARG,
logLevel: env.OTEL_LOG_LEVEL,
},
/** IBM i DB2 configuration. Undefined if no related env vars are set. */
db2i:
env.DB2i_HOST && env.DB2i_USER && env.DB2i_PASS
? {
host: env.DB2i_HOST,
user: env.DB2i_USER,
password: env.DB2i_PASS,
ignoreUnauthorized: env.DB2i_IGNORE_UNAUTHORIZED,
}
: undefined,
/** Path to YAML tools configuration file. From `TOOLS_YAML_PATH`. */
toolsYamlPath: env.TOOLS_YAML_PATH,
/** YAML configuration merge options. From `YAML_MERGE_*` environment variables. */
yamlMergeOptions: {
mergeArrays: env.YAML_MERGE_ARRAYS,
allowDuplicateTools: env.YAML_ALLOW_DUPLICATE_TOOLS,
allowDuplicateSources: env.YAML_ALLOW_DUPLICATE_SOURCES,
validateMerged: env.YAML_VALIDATE_MERGED,
},
/** IBM i HTTP Authentication configuration. From `IBMI_AUTH_*` environment variables. */
ibmiHttpAuth: {
enabled: env.IBMI_HTTP_AUTH_ENABLED,
allowHttp: env.IBMI_AUTH_ALLOW_HTTP,
tokenExpirySeconds: env.IBMI_AUTH_TOKEN_EXPIRY_SECONDS,
cleanupIntervalSeconds: env.IBMI_AUTH_CLEANUP_INTERVAL_SECONDS,
maxConcurrentSessions: env.IBMI_AUTH_MAX_CONCURRENT_SESSIONS,
privateKeyPath: env.IBMI_AUTH_PRIVATE_KEY_PATH,
publicKeyPath: env.IBMI_AUTH_PUBLIC_KEY_PATH,
keyId: env.IBMI_AUTH_KEY_ID,
},
/** Enable automatic reloading of YAML tools when configuration files change. From `YAML_AUTO_RELOAD`. Default: true. */
yamlAutoReload: env.YAML_AUTO_RELOAD,
/** Selected toolsets for filtering tools. Set via CLI --toolsets option or SELECTED_TOOLSETS environment variable. */
selectedToolsets: env.SELECTED_TOOLSETS?.split(",")
.map((ts) => ts.trim())
.filter(Boolean) as string[] | undefined,
};
if (config.ibmiHttpAuth.enabled) {
const missing: string[] = [];
if (!config.ibmiHttpAuth.privateKeyPath) {
missing.push("IBMI_AUTH_PRIVATE_KEY_PATH");
}
if (!config.ibmiHttpAuth.publicKeyPath) {
missing.push("IBMI_AUTH_PUBLIC_KEY_PATH");
}
if (!config.ibmiHttpAuth.keyId) {
missing.push("IBMI_AUTH_KEY_ID");
}
if (missing.length > 0) {
throw new Error(
`IBM i HTTP auth is enabled but missing required key configuration: ${missing.join(", ")}`,
);
}
}
export const logLevel: string = config.logLevel;
export const environment: string = config.environment;