|
@@ -0,0 +1,227 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * MCP Configuration Manager
|
|
|
|
|
+ *
|
|
|
|
|
+ * Manages per-repository MCP server configurations with support for:
|
|
|
|
|
+ * - Repository-specific configurations
|
|
|
|
|
+ * - Global default configuration
|
|
|
|
|
+ * - Environment variable substitution for secrets
|
|
|
|
|
+ * - Secure file storage with restricted permissions
|
|
|
|
|
+ */
|
|
|
|
|
+
|
|
|
|
|
+import { readFileSync, existsSync, mkdirSync, writeFileSync, statSync, chmodSync } from 'fs';
|
|
|
|
|
+import { join, dirname } from 'path';
|
|
|
|
|
+import { homedir } from 'os';
|
|
|
|
|
+import { MCPServerConfig } from './types.js';
|
|
|
|
|
+
|
|
|
|
|
+export class McpConfigManager {
|
|
|
|
|
+ private static readonly CONFIG_DIR = join(homedir(), '.config', 'agent-manager', 'mcp');
|
|
|
|
|
+ private static readonly DEFAULT_CONFIG_FILE = 'default.json';
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Get the configuration directory path
|
|
|
|
|
+ */
|
|
|
|
|
+ static getConfigDir(): string {
|
|
|
|
|
+ return this.CONFIG_DIR;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Get the path to a repository-specific config file
|
|
|
|
|
+ */
|
|
|
|
|
+ static getRepoConfigPath(owner: string, repo: string): string {
|
|
|
|
|
+ return join(this.CONFIG_DIR, owner, `${repo}.json`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Get the path to the global default config file
|
|
|
|
|
+ */
|
|
|
|
|
+ static getDefaultConfigPath(): string {
|
|
|
|
|
+ return join(this.CONFIG_DIR, this.DEFAULT_CONFIG_FILE);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Initialize the configuration directory structure
|
|
|
|
|
+ * Creates the base directory if it doesn't exist
|
|
|
|
|
+ */
|
|
|
|
|
+ static initializeConfigDir(): void {
|
|
|
|
|
+ if (!existsSync(this.CONFIG_DIR)) {
|
|
|
|
|
+ mkdirSync(this.CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
|
|
|
+ console.log(`Created MCP config directory: ${this.CONFIG_DIR}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Substitute environment variables in a string
|
|
|
|
|
+ * Supports ${VAR_NAME} syntax
|
|
|
|
|
+ */
|
|
|
|
|
+ private static substituteEnvVars(value: string): string {
|
|
|
|
|
+ return value.replace(/\$\{([^}]+)\}/g, (_, varName) => {
|
|
|
|
|
+ return process.env[varName] || '';
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Recursively substitute environment variables in an object
|
|
|
|
|
+ */
|
|
|
|
|
+ private static substituteEnvVarsInObject(obj: any): any {
|
|
|
|
|
+ if (typeof obj === 'string') {
|
|
|
|
|
+ return this.substituteEnvVars(obj);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (Array.isArray(obj)) {
|
|
|
|
|
+ return obj.map(item => this.substituteEnvVarsInObject(item));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (obj && typeof obj === 'object') {
|
|
|
|
|
+ const result: any = {};
|
|
|
|
|
+ for (const [key, value] of Object.entries(obj)) {
|
|
|
|
|
+ result[key] = this.substituteEnvVarsInObject(value);
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return obj;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Load and parse a configuration file
|
|
|
|
|
+ */
|
|
|
|
|
+ private static loadConfigFile(filePath: string): MCPServerConfig | null {
|
|
|
|
|
+ try {
|
|
|
|
|
+ // Check file permissions for security
|
|
|
|
|
+ const stats = statSync(filePath);
|
|
|
|
|
+ const mode = stats.mode & 0o777;
|
|
|
|
|
+
|
|
|
|
|
+ // Warn if file is readable by others (group or world)
|
|
|
|
|
+ if (mode & 0o077) {
|
|
|
|
|
+ console.warn(`Warning: MCP config file ${filePath} has insecure permissions (${mode.toString(8)}). Should be 600 or 400.`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const content = readFileSync(filePath, 'utf-8');
|
|
|
|
|
+ const config = JSON.parse(content) as MCPServerConfig;
|
|
|
|
|
+
|
|
|
|
|
+ // Substitute environment variables in the config
|
|
|
|
|
+ const substituted = this.substituteEnvVarsInObject(config);
|
|
|
|
|
+
|
|
|
|
|
+ return substituted;
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
|
|
|
+ return null; // File doesn't exist
|
|
|
|
|
+ }
|
|
|
|
|
+ console.error(`Error loading MCP config from ${filePath}:`, error);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Get MCP configuration for a specific repository
|
|
|
|
|
+ *
|
|
|
|
|
+ * Priority:
|
|
|
|
|
+ * 1. Repository-specific config: ~/.config/agent-manager/mcp/{owner}/{repo}.json
|
|
|
|
|
+ * 2. Global default config: ~/.config/agent-manager/mcp/default.json
|
|
|
|
|
+ * 3. Hardcoded fallback (current behavior)
|
|
|
|
|
+ */
|
|
|
|
|
+ static getMcpConfig(owner: string, repo: string, gogsMcpPath: string): MCPServerConfig {
|
|
|
|
|
+ // Ensure config directory exists
|
|
|
|
|
+ this.initializeConfigDir();
|
|
|
|
|
+
|
|
|
|
|
+ // Try repository-specific config
|
|
|
|
|
+ const repoConfigPath = this.getRepoConfigPath(owner, repo);
|
|
|
|
|
+ const repoConfig = this.loadConfigFile(repoConfigPath);
|
|
|
|
|
+
|
|
|
|
|
+ if (repoConfig) {
|
|
|
|
|
+ console.log(`Using repository-specific MCP config: ${repoConfigPath}`);
|
|
|
|
|
+ return repoConfig;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Try global default config
|
|
|
|
|
+ const defaultConfigPath = this.getDefaultConfigPath();
|
|
|
|
|
+ const defaultConfig = this.loadConfigFile(defaultConfigPath);
|
|
|
|
|
+
|
|
|
|
|
+ if (defaultConfig) {
|
|
|
|
|
+ console.log(`Using global default MCP config: ${defaultConfigPath}`);
|
|
|
|
|
+ return defaultConfig;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Fallback to hardcoded config
|
|
|
|
|
+ console.log('Using hardcoded default MCP config (gogs-mcp only)');
|
|
|
|
|
+ return {
|
|
|
|
|
+ mcpServers: {
|
|
|
|
|
+ gogs: {
|
|
|
|
|
+ type: 'stdio',
|
|
|
|
|
+ command: 'node',
|
|
|
|
|
+ args: [gogsMcpPath],
|
|
|
|
|
+ env: {}
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Create a repository-specific configuration file
|
|
|
|
|
+ * This is a helper method for setting up new configurations
|
|
|
|
|
+ */
|
|
|
|
|
+ static createRepoConfig(owner: string, repo: string, config: MCPServerConfig): void {
|
|
|
|
|
+ const configPath = this.getRepoConfigPath(owner, repo);
|
|
|
|
|
+ const configDir = dirname(configPath);
|
|
|
|
|
+
|
|
|
|
|
+ // Create owner directory if it doesn't exist
|
|
|
|
|
+ if (!existsSync(configDir)) {
|
|
|
|
|
+ mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Write config file
|
|
|
|
|
+ writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
|
|
|
+
|
|
|
|
|
+ console.log(`Created repository config: ${configPath}`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Create the global default configuration file
|
|
|
|
|
+ */
|
|
|
|
|
+ static createDefaultConfig(config: MCPServerConfig): void {
|
|
|
|
|
+ this.initializeConfigDir();
|
|
|
|
|
+
|
|
|
|
|
+ const configPath = this.getDefaultConfigPath();
|
|
|
|
|
+ writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
|
|
|
+
|
|
|
|
|
+ console.log(`Created default MCP config: ${configPath}`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * List all repository-specific configurations
|
|
|
|
|
+ */
|
|
|
|
|
+ static listRepoConfigs(): Array<{ owner: string; repo: string; path: string }> {
|
|
|
|
|
+ const configs: Array<{ owner: string; repo: string; path: string }> = [];
|
|
|
|
|
+
|
|
|
|
|
+ if (!existsSync(this.CONFIG_DIR)) {
|
|
|
|
|
+ return configs;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { readdirSync } = require('fs');
|
|
|
|
|
+ const owners = readdirSync(this.CONFIG_DIR, { withFileTypes: true })
|
|
|
|
|
+ .filter((dirent: any) => dirent.isDirectory())
|
|
|
|
|
+ .map((dirent: any) => dirent.name);
|
|
|
|
|
+
|
|
|
|
|
+ for (const owner of owners) {
|
|
|
|
|
+ const ownerDir = join(this.CONFIG_DIR, owner);
|
|
|
|
|
+ const files = readdirSync(ownerDir, { withFileTypes: true })
|
|
|
|
|
+ .filter((dirent: any) => dirent.isFile() && dirent.name.endsWith('.json'))
|
|
|
|
|
+ .map((dirent: any) => dirent.name);
|
|
|
|
|
+
|
|
|
|
|
+ for (const file of files) {
|
|
|
|
|
+ const repo = file.replace(/\.json$/, '');
|
|
|
|
|
+ configs.push({
|
|
|
|
|
+ owner,
|
|
|
|
|
+ repo,
|
|
|
|
|
+ path: join(ownerDir, file)
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('Error listing repo configs:', error);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return configs;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|