@cyanheads/pubmed-mcp-server
Version:
A Model Context Protocol (MCP) server enabling AI agents to intelligently search, retrieve, and analyze biomedical literature from PubMed via NCBI E-utilities. Built on the mcp-ts-template for robust, production-ready performance.
341 lines (340 loc) • 15.7 kB
JavaScript
/**
* @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.
*
* Key responsibilities:
* - Load environment variables from a `.env` file.
* - Read `package.json` for default server name and version.
* - Define a Zod schema for all expected environment variables.
* - Validate environment variables against the schema.
* - Construct and export a comprehensive `config` object.
* - Export individual configuration values like `logLevel` and `environment` for convenience.
*
* @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 ---
/**
* Finds the project root directory by searching upwards for package.json.
* @param startDir The directory to start searching from.
* @returns The absolute path to the project root, or throws an error if not found.
*/
const findProjectRoot = (startDir) => {
let currentDir = startDir;
while (true) {
const packageJsonPath = join(currentDir, "package.json");
if (existsSync(packageJsonPath)) {
return currentDir;
}
const parentDir = dirname(currentDir);
if (parentDir === currentDir) {
// Reached the root of the filesystem without finding package.json
throw new Error(`Could not find project root (package.json) starting from ${startDir}`);
}
currentDir = parentDir;
}
};
let projectRoot;
try {
// For ESM, __dirname is not available directly.
// import.meta.url gives the URL of the current module.
const currentModuleDir = dirname(fileURLToPath(import.meta.url));
projectRoot = findProjectRoot(currentModuleDir);
}
catch (error) {
console.error(`FATAL: Error determining project root: ${error.message}`);
// Fallback to process.cwd() if project root cannot be determined.
// This might happen in unusual execution environments.
projectRoot = process.cwd();
console.warn(`Warning: Using process.cwd() (${projectRoot}) as fallback project root.`);
}
// --- End Determine Project Root ---
const pkgPath = join(projectRoot, "package.json"); // Use determined projectRoot
let pkg = { name: "mcp-ts-template", version: "0.0.0" };
try {
pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
}
catch (error) {
if (process.stdout.isTTY) {
console.error("Warning: Could not read package.json for default config values. Using hardcoded defaults.", error);
}
}
/**
* Zod schema for validating environment variables.
* Provides type safety, validation, defaults, and clear error messages.
* @private
*/
const EnvSchema = z
.object({
/** Optional. The desired name for the MCP server. Defaults to `package.json` name. */
MCP_SERVER_NAME: z.string().optional(),
/** Optional. The version of the MCP server. Defaults to `package.json` version. */
MCP_SERVER_VERSION: z.string().optional(),
/** Minimum logging level. See `McpLogLevel` in logger utility. Default: "debug". */
MCP_LOG_LEVEL: z.string().default("debug"),
/** Directory for log files. Defaults to "logs" in project root. */
LOGS_DIR: z.string().default(path.join(projectRoot, "logs")),
/** Defines the logging output mode. "file" for logs in LOGS_DIR, "stdout" for console logging. */
LOG_OUTPUT_MODE: z.enum(["file", "stdout"]).default("file"),
/** Runtime environment (e.g., "development", "production"). Default: "development". */
NODE_ENV: z.string().default("development"),
/** MCP communication transport ("stdio" or "http"). Default: "stdio". */
MCP_TRANSPORT_TYPE: z.enum(["stdio", "http"]).default("stdio"),
/** HTTP server port (if MCP_TRANSPORT_TYPE is "http"). Default: 3010. */
MCP_HTTP_PORT: z.coerce.number().int().positive().default(3010),
/** HTTP server host (if MCP_TRANSPORT_TYPE is "http"). Default: "127.0.0.1". */
MCP_HTTP_HOST: z.string().default("127.0.0.1"),
/** Optional. Comma-separated allowed origins for CORS (HTTP transport). */
MCP_ALLOWED_ORIGINS: z.string().optional(),
/** Optional. Secret key (min 32 chars) for auth tokens (HTTP transport). CRITICAL for production. */
MCP_AUTH_SECRET_KEY: z
.string()
.min(32, "MCP_AUTH_SECRET_KEY must be at least 32 characters long for security reasons.")
.optional(),
/** Authentication mode ('jwt' or 'oauth'). Default: 'jwt'. */
MCP_AUTH_MODE: z.enum(["jwt", "oauth"]).default("jwt"),
/** OAuth: The expected issuer of the JWT. */
OAUTH_ISSUER_URL: z.string().url().optional(),
/** OAuth: The expected audience of the JWT. */
OAUTH_AUDIENCE: z.string().optional(),
/** OAuth: The URI of the JWKS endpoint. */
OAUTH_JWKS_URI: z.string().url().optional(),
/** Optional. OAuth provider authorization endpoint URL. */
OAUTH_PROXY_AUTHORIZATION_URL: z
.string()
.url("OAUTH_PROXY_AUTHORIZATION_URL must be a valid URL.")
.optional(),
/** Optional. OAuth provider token endpoint URL. */
OAUTH_PROXY_TOKEN_URL: z
.string()
.url("OAUTH_PROXY_TOKEN_URL must be a valid URL.")
.optional(),
/** Optional. OAuth provider revocation endpoint URL. */
OAUTH_PROXY_REVOCATION_URL: z
.string()
.url("OAUTH_PROXY_REVOCATION_URL must be a valid URL.")
.optional(),
/** Optional. OAuth provider issuer URL. */
OAUTH_PROXY_ISSUER_URL: z
.string()
.url("OAUTH_PROXY_ISSUER_URL must be a valid URL.")
.optional(),
/** Optional. OAuth service documentation URL. */
OAUTH_PROXY_SERVICE_DOCUMENTATION_URL: z
.string()
.url("OAUTH_PROXY_SERVICE_DOCUMENTATION_URL must be a valid URL.")
.optional(),
/** Optional. Comma-separated default OAuth client redirect URIs. */
OAUTH_PROXY_DEFAULT_CLIENT_REDIRECT_URIS: z.string().optional(),
// NCBI E-utilities Configuration
/** NCBI API Key. Optional, but highly recommended for higher rate limits. */
NCBI_API_KEY: z.string().optional(),
/** Tool identifier sent to NCBI. Defaults to MCP_SERVER_NAME/MCP_SERVER_VERSION. */
NCBI_TOOL_IDENTIFIER: z.string().optional(),
/** Administrator's email for NCBI contact. Optional, but recommended if using an API key. */
NCBI_ADMIN_EMAIL: z
.string()
.email("NCBI_ADMIN_EMAIL must be a valid email address.")
.optional(),
/** Milliseconds to wait between NCBI requests. Default: 100 (for API key), 334 (without API key). */
NCBI_REQUEST_DELAY_MS: z.coerce.number().int().positive().optional(), // Default will be set conditionally
/** Maximum number of retries for failed NCBI requests. Default: 3. */
NCBI_MAX_RETRIES: z.coerce.number().int().nonnegative().default(3),
})
.superRefine((data, ctx) => {
// Rule 1: MCP_AUTH_SECRET_KEY is required for http transport in production with jwt auth
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 with 'http' transport in a 'production' environment.",
});
}
// Rule 2: Core OAuth variables are required when MCP_AUTH_MODE is 'oauth'
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 when MCP_AUTH_MODE is 'oauth'.",
});
}
if (!data.OAUTH_AUDIENCE) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["OAUTH_AUDIENCE"],
message: "OAUTH_AUDIENCE is required when MCP_AUTH_MODE is 'oauth'.",
});
}
if (!data.OAUTH_JWKS_URI) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["OAUTH_JWKS_URI"],
message: "OAUTH_JWKS_URI is required when MCP_AUTH_MODE is 'oauth'.",
});
}
}
});
const parsedEnv = EnvSchema.safeParse(process.env);
if (!parsedEnv.success) {
if (process.stdout.isTTY) {
console.error("❌ Invalid environment variables found:", parsedEnv.error.flatten().fieldErrors);
}
// Consider throwing an error in production for critical misconfigurations.
}
const env = parsedEnv.success ? parsedEnv.data : EnvSchema.parse({});
// --- Directory Ensurance Function ---
/**
* Ensures a directory exists and is within the project root.
* @param dirPath The desired path for the directory (can be relative or absolute).
* @param rootDir The root directory of the project to contain the directory.
* @param dirName The name of the directory type for logging (e.g., "logs").
* @returns The validated, absolute path to the directory, or null if invalid.
*/
const ensureDirectory = (dirPath, rootDir, dirName) => {
const resolvedDirPath = path.isAbsolute(dirPath)
? dirPath
: path.resolve(rootDir, dirPath);
// Ensure the resolved path is within the project root boundary
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) {
if (process.stdout.isTTY) {
console.error(`Error accessing ${dirName} path ${resolvedDirPath}: ${statError.message}`);
}
return null;
}
}
return resolvedDirPath;
};
// --- End Directory Ensurance Function ---
// --- Logs Directory Handling ---
let validatedLogsPath = null;
if (env.LOG_OUTPUT_MODE === "file") {
validatedLogsPath = ensureDirectory(env.LOGS_DIR, projectRoot, "logs");
if (!validatedLogsPath) {
if (process.stdout.isTTY) {
console.error("FATAL: Log mode is 'file' but logs directory is invalid or could not be created. Please check LOGS_DIR, permissions, and path. Exiting.");
}
process.exit(1); // Exit if file logging is configured but directory is not usable
}
}
// --- End Logs Directory Handling ---
/**
* Main application configuration object.
* Aggregates settings from validated environment variables and `package.json`.
*/
export const config = {
/** MCP server name. Env `MCP_SERVER_NAME` > `package.json` name > "mcp-ts-template". */
mcpServerName: env.MCP_SERVER_NAME || pkg.name,
/** MCP server version. Env `MCP_SERVER_VERSION` > `package.json` version > "0.0.0". */
mcpServerVersion: env.MCP_SERVER_VERSION || pkg.version,
/** Logging level. From `MCP_LOG_LEVEL` env var. Default: "debug". */
logLevel: env.MCP_LOG_LEVEL,
/** Defines the logging output mode ('file' or 'stdout'). From `LOG_OUTPUT_MODE`. */
logOutputMode: env.LOG_OUTPUT_MODE,
/** Absolute path to the logs directory (if logOutputMode is 'file'). From `LOGS_DIR`. */
logsPath: validatedLogsPath,
/** Runtime environment. From `NODE_ENV` env var. Default: "development". */
environment: env.NODE_ENV,
/** MCP transport type ('stdio' or 'http'). From `MCP_TRANSPORT_TYPE` env var. Default: "stdio". */
mcpTransportType: env.MCP_TRANSPORT_TYPE,
/** HTTP server port (if http transport). From `MCP_HTTP_PORT` env var. Default: 3010. */
mcpHttpPort: env.MCP_HTTP_PORT,
/** HTTP server host (if http transport). From `MCP_HTTP_HOST` env var. Default: "127.0.0.1". */
mcpHttpHost: env.MCP_HTTP_HOST,
/** Array of allowed CORS origins (http transport). From `MCP_ALLOWED_ORIGINS` (comma-separated). */
mcpAllowedOrigins: env.MCP_ALLOWED_ORIGINS?.split(",")
.map((origin) => origin.trim())
.filter(Boolean),
/** Auth secret key (JWTs, http transport). From `MCP_AUTH_SECRET_KEY`. CRITICAL. */
mcpAuthSecretKey: env.MCP_AUTH_SECRET_KEY,
/** Auth mode ('jwt' or 'oauth'). From `MCP_AUTH_MODE`. */
mcpAuthMode: env.MCP_AUTH_MODE,
/** OAuth Issuer URL. From `OAUTH_ISSUER_URL`. */
oauthIssuerUrl: env.OAUTH_ISSUER_URL,
/** OAuth Audience. From `OAUTH_AUDIENCE`. */
oauthAudience: env.OAUTH_AUDIENCE,
/** OAuth JWKS URI. From `OAUTH_JWKS_URI`. */
oauthJwksUri: env.OAUTH_JWKS_URI,
// NCBI Configuration
/** NCBI API Key. From `NCBI_API_KEY`. */
ncbiApiKey: env.NCBI_API_KEY,
/** NCBI Tool Identifier. From `NCBI_TOOL_IDENTIFIER`. Defaults to server name/version. */
ncbiToolIdentifier: env.NCBI_TOOL_IDENTIFIER ||
`${env.MCP_SERVER_NAME || pkg.name}/${env.MCP_SERVER_VERSION || pkg.version}`,
/** NCBI Admin Email. From `NCBI_ADMIN_EMAIL`. */
ncbiAdminEmail: env.NCBI_ADMIN_EMAIL,
/** NCBI Request Delay in MS. From `NCBI_REQUEST_DELAY_MS`. Dynamically set based on API key presence. */
ncbiRequestDelayMs: env.NCBI_REQUEST_DELAY_MS ?? (env.NCBI_API_KEY ? 100 : 334),
/** NCBI Max Retries. From `NCBI_MAX_RETRIES`. */
ncbiMaxRetries: env.NCBI_MAX_RETRIES,
/** OAuth Proxy configurations. Undefined if no related env vars are set. */
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,
};
/**
* Configured logging level for the application.
* Exported for convenience.
*/
export const logLevel = config.logLevel;
/**
* Configured runtime environment ("development", "production", etc.).
* Exported for convenience.
*/
export const environment = config.environment;