pocketsmith-mcp
Version:
MCP server for managing budgets via PocketSmith API
167 lines (166 loc) • 7.98 kB
JavaScript
/**
* @fileoverview Loads and validates MCP client configuration from a JSON file.
* This module defines Zod schemas for the configuration structure, provides functions
* to load configuration from `mcp-config.json`, and retrieves specific server
* configurations.
* @module src/mcp-client/client-config/configLoader
*/
import { existsSync, readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { z } from "zod";
import { BaseErrorCode, McpError } from "../../types-global/errors.js";
import { logger, requestContextService, } from "../../utils/index.js";
// --- Zod Schemas for Configuration Validation ---
/**
* Zod schema for environment variables passed to an MCP server process.
* Allows defining specific environment variables for each server.
* @private
*/
const EnvSchema = z
.record(z.string())
.optional()
.describe("Optional key-value pairs for environment variables specific to the server process.");
/**
* Zod schema for a single MCP server's configuration entry.
* Defines the command, arguments, environment variables, and transport type for an MCP server.
* For HTTP transport, `command` holds the base URL.
*/
export const McpServerConfigEntrySchema = z.object({
command: z.string().min(1, "Server command or HTTP base URL cannot be empty"),
args: z
.array(z.string())
.default([])
.describe("Arguments for the server command (stdio transport only)."),
env: EnvSchema.describe("Optional environment variables for this server (merged with client's env)."),
transportType: z
.enum(["stdio", "http"])
.default("stdio")
.describe("Communication transport type ('stdio' or 'http')."),
disabled: z
.boolean()
.optional()
.describe("If true, this server configuration is ignored."),
autoApprove: z
.boolean()
.optional()
.describe("If true, skip user approval prompts for this server (use with caution)."),
});
/**
* Zod schema for the root structure of the `mcp-config.json` file.
* It expects a top-level key `mcpServers` containing a map of server names to their configurations.
*/
export const McpClientConfigFileSchema = z.object({
mcpServers: z
.record(McpServerConfigEntrySchema)
.describe("A map of server names to their configurations."),
});
// --- Configuration Loading Logic ---
const __dirname = dirname(fileURLToPath(import.meta.url));
// Path when running from dist: __dirname is .../dist/mcp-client/client-config
// We want to reach: .../src/mcp-client/client-config/mcp-config.json
const primaryConfigPath = join(__dirname, "../../../src/mcp-client/client-config/mcp-config.json");
let loadedConfig = null;
let loadedConfigPath = null;
/**
* Loads, validates, and caches the MCP client configuration from `mcp-config.json`.
* The configuration is validated against {@link McpClientConfigFileSchema}.
*
* @param parentContext - Optional parent request context for logging and tracing.
* @returns The loaded and validated MCP server configurations object.
* @throws {McpError} If the config file cannot be read, or if parsing or validation fails.
*/
export function loadMcpClientConfig(parentContext) {
const context = requestContextService.createRequestContext({
...(parentContext ?? {}),
operation: "loadMcpClientConfig",
});
if (loadedConfig && loadedConfigPath) {
logger.debug(`Returning cached MCP client config from: ${loadedConfigPath}`, context);
return loadedConfig;
}
let fileContent;
const configPathToLog = primaryConfigPath; // Only attempt to load the primary config
if (!existsSync(primaryConfigPath)) {
logger.error(`MCP client config file not found at ${primaryConfigPath}.`, {
...context,
filePath: primaryConfigPath,
});
throw new McpError(BaseErrorCode.CONFIGURATION_ERROR, `MCP client config file not found: ${primaryConfigPath} does not exist.`, context);
}
logger.info(`Attempting to load MCP config from: ${primaryConfigPath}`, context);
try {
fileContent = readFileSync(primaryConfigPath, "utf-8");
logger.info(`Successfully read config file: ${primaryConfigPath}`, {
...context,
filePath: configPathToLog,
});
}
catch (readError) {
logger.error(`Failed to read MCP client config file: ${primaryConfigPath}`, {
...context,
filePath: primaryConfigPath,
error: readError instanceof Error ? readError.message : String(readError),
});
throw new McpError(BaseErrorCode.CONFIGURATION_ERROR, `Failed to read MCP client config file ${primaryConfigPath}: ${readError instanceof Error ? readError.message : String(readError)}`, { originalError: readError, ...context });
}
try {
const parsedJson = JSON.parse(fileContent);
const validationResult = McpClientConfigFileSchema.safeParse(parsedJson);
if (!validationResult.success) {
logger.error("MCP client configuration validation failed.", {
...context,
filePath: configPathToLog,
errors: validationResult.error.errors,
});
const errorMessages = validationResult.error.errors
.map((e) => `${e.path.join(".")}: ${e.message}`)
.join("; ");
// The comment about ErrorHandlerOption was here, removing it.
throw new Error(`Validation failed: ${errorMessages}`);
}
loadedConfig = validationResult.data;
loadedConfigPath = configPathToLog;
logger.info(`MCP client configuration loaded and validated successfully from: ${loadedConfigPath}`, {
...context,
serversFound: Object.keys(loadedConfig.mcpServers).length,
});
return loadedConfig;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error("Failed to parse or validate MCP client configuration", {
...context,
filePath: configPathToLog,
error: errorMessage,
stack: error instanceof Error ? error.stack : undefined,
});
throw new McpError(BaseErrorCode.CONFIGURATION_ERROR, `Failed to load/validate MCP client config from ${configPathToLog}: ${errorMessage}`, { originalError: error, ...context });
}
}
/**
* Retrieves a copy of the configuration entry for a specific MCP server by its name.
* This function ensures the main configuration is loaded before accessing server details.
*
* @param serverName - The name/identifier of the server as defined in the configuration file.
* @param parentContext - Optional parent request context for consistent logging.
* @returns A copy of the configuration for the specified server.
* @throws {McpError} If the main configuration cannot be loaded, or if the specified `serverName` is not found.
*/
export function getMcpServerConfig(serverName, parentContext) {
const context = requestContextService.createRequestContext({
...(parentContext ?? {}),
operation: "getMcpServerConfig",
targetServer: serverName,
});
const config = loadMcpClientConfig(context);
const configPath = loadedConfigPath || "unknown (cached or error)";
const serverConfig = config.mcpServers[serverName];
if (!serverConfig) {
logger.error(`Configuration for MCP server "${serverName}" not found in ${configPath}.`, context);
throw new McpError(BaseErrorCode.CONFIGURATION_ERROR, `Configuration for MCP server "${serverName}" not found in ${configPath}.`, context);
}
logger.debug(`Retrieved configuration for server "${serverName}" from ${configPath}`, context);
// Return a deep copy to prevent accidental modification of the cached config
return JSON.parse(JSON.stringify(serverConfig));
}