UNPKG

@cyanheads/pubmed-mcp-server

Version:

Production-ready PubMed Model Context Protocol (MCP) server that empowers AI agents and research tools with comprehensive access to PubMed's article database. Enables advanced, automated LLM workflows for searching, retrieving, analyzing, and visualizing

287 lines 11.8 kB
/** * @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 path, { dirname, join } from "path"; import { fileURLToPath } from "url"; import { z } from "zod"; dotenv.config(); // --- Determine Project Root --- const findProjectRoot = (startDir) => { 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; try { const currentModuleDir = dirname(fileURLToPath(import.meta.url)); projectRoot = findProjectRoot(currentModuleDir); } catch (error) { 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 = () => { const pkgPath = join(projectRoot, "package.json"); const fallback = { name: "pubmed-mcp-server", version: "0.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({ // Core Server Config MCP_SERVER_NAME: z.string().optional(), MCP_SERVER_VERSION: z.string().optional(), NODE_ENV: z.string().default("development"), // Logging MCP_LOG_LEVEL: z.string().default("debug"), LOGS_DIR: z.string().default(path.join(projectRoot, "logs")), // Transport 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(3017), MCP_HTTP_HOST: z.string().default("127.0.0.1"), 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(), // Authentication MCP_AUTH_MODE: z.enum(["jwt", "oauth", "none"]).default("none"), MCP_AUTH_SECRET_KEY: z .string() .min(32, "MCP_AUTH_SECRET_KEY must be at least 32 characters long.") .optional(), OAUTH_ISSUER_URL: z.string().url().optional(), OAUTH_JWKS_URI: z.string().url().optional(), OAUTH_AUDIENCE: z.string().optional(), // Dev mode JWT DEV_MCP_CLIENT_ID: z.string().optional(), DEV_MCP_SCOPES: z.string().optional(), // NCBI E-utilities NCBI_API_KEY: z.string().optional(), NCBI_TOOL_IDENTIFIER: z.string().optional(), NCBI_ADMIN_EMAIL: z.string().email().optional(), NCBI_REQUEST_DELAY_MS: z.coerce.number().int().positive().optional(), NCBI_MAX_RETRIES: z.coerce.number().int().nonnegative().default(3), // --- 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"), }) .superRefine((data, ctx) => { if (data.NODE_ENV === "production" && data.MCP_TRANSPORT_TYPE === "http" && data.MCP_AUTH_MODE === "jwt" && !data.MCP_AUTH_SECRET_KEY) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["MCP_AUTH_SECRET_KEY"], message: "MCP_AUTH_SECRET_KEY is required for 'jwt' auth in production with 'http' transport.", }); } if (data.MCP_AUTH_MODE === "oauth") { if (!data.OAUTH_ISSUER_URL) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["OAUTH_ISSUER_URL"], message: "OAUTH_ISSUER_URL is required for 'oauth' mode.", }); } if (!data.OAUTH_AUDIENCE) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["OAUTH_AUDIENCE"], message: "OAUTH_AUDIENCE is required for 'oauth' mode.", }); } } }); const parsedEnv = EnvSchema.safeParse(process.env); if (!parsedEnv.success) { if (process.stdout.isTTY) { console.error("❌ Invalid environment variables:", parsedEnv.error.flatten().fieldErrors); } } const env = parsedEnv.success ? parsedEnv.data : EnvSchema.parse({}); const ensureDirectory = (dirPath, rootDir, dirName) => { const resolvedDirPath = path.isAbsolute(dirPath) ? dirPath : path.resolve(rootDir, dirPath); if (!resolvedDirPath.startsWith(rootDir + path.sep) && resolvedDirPath !== rootDir) { if (process.stdout.isTTY) { console.error(`Error: ${dirName} path "${dirPath}" resolves to "${resolvedDirPath}", which is outside the project boundary "${rootDir}".`); } return null; } if (!existsSync(resolvedDirPath)) { try { mkdirSync(resolvedDirPath, { recursive: true }); if (process.stdout.isTTY) { console.log(`Created ${dirName} directory: ${resolvedDirPath}`); } } catch (err) { 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 { 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) { const errorMessage = statError instanceof Error ? statError.message : "An unknown error occurred"; if (process.stdout.isTTY) { console.error(`Error accessing ${dirName} path ${resolvedDirPath}: ${errorMessage}`); } return null; } } return resolvedDirPath; }; let validatedLogsPath = ensureDirectory(env.LOGS_DIR, projectRoot, "logs"); if (!validatedLogsPath) { if (process.stdout.isTTY) { console.warn(`Warning: Custom logs directory ('${env.LOGS_DIR}') is invalid or outside the project boundary. Falling back to default.`); } const defaultLogsDir = path.join(projectRoot, "logs"); validatedLogsPath = ensureDirectory(defaultLogsDir, projectRoot, "logs"); if (!validatedLogsPath) { if (process.stdout.isTTY) { console.warn("Warning: Default logs directory could not be created. 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((o) => o.trim()) .filter(Boolean), mcpAuthMode: env.MCP_AUTH_MODE, mcpAuthSecretKey: env.MCP_AUTH_SECRET_KEY, 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()), ncbiApiKey: env.NCBI_API_KEY, ncbiToolIdentifier: env.NCBI_TOOL_IDENTIFIER || `${env.MCP_SERVER_NAME || pkg.name}/${env.MCP_SERVER_VERSION || pkg.version}`, ncbiAdminEmail: env.NCBI_ADMIN_EMAIL, ncbiRequestDelayMs: env.NCBI_REQUEST_DELAY_MS ?? (env.NCBI_API_KEY ? 100 : 334), ncbiMaxRetries: env.NCBI_MAX_RETRIES, 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, }, }; export const logLevel = config.logLevel; export const environment = config.environment; //# sourceMappingURL=index.js.map