diff --git a/README.md b/README.md index bec72dd..395e330 100644 --- a/README.md +++ b/README.md @@ -86,50 +86,37 @@ The server provides the following ArgoCD management tools: ### Usage with VSCode -1. Follow the [Use MCP servers in VS Code documentation](https://code.visualstudio.com/docs/copilot/chat/mcp-servers), and create a `.vscode/mcp.json` file in your project: -```json -{ - "servers": { - "argocd-mcp-stdio": { - "type": "stdio", - "command": "npx", - "args": [ - "argocd-mcp@latest", - "stdio" - ], - "env": { - "ARGOCD_BASE_URL": "", - "ARGOCD_API_TOKEN": "" - } - } - } -} +1. Enable the ArgoCD MCP server in VS Code: +```bash +npx argocd-mcp@latest vscode enable --url --token ``` +Optionally, use the `--workspace` flag to install in the current workspace directory instead of the user configuration directory. + +You can also set the `ARGOCD_BASE_URL` and `ARGOCD_API_TOKEN` environment variables instead of using the `--url` and `--token` flags. + 2. Start a conversation with an AI assistant in VS Code that supports MCP. +To disable the server, run: +```bash +npx argocd-mcp@latest vscode disable +``` + ### Usage with Claude Desktop -1. Follow the [MCP in Claude Desktop documentation](https://modelcontextprotocol.io/quickstart/user), and create a `claude_desktop_config.json` configuration file: -```json -{ - "mcpServers": { - "argocd-mcp": { - "command": "npx", - "args": [ - "argocd-mcp@latest", - "stdio" - ], - "env": { - "ARGOCD_BASE_URL": "", - "ARGOCD_API_TOKEN": "" - } - } - } -} +1. Enable the ArgoCD MCP server in Claude Desktop: +```bash +npx argocd-mcp@latest claude enable --url --token ``` -2. Configure Claude Desktop to use this configuration file in settings. +You can also set the `ARGOCD_BASE_URL` and `ARGOCD_API_TOKEN` environment variables instead of using the `--url` and `--token` flags. + +2. Restart Claude Desktop to load the configuration. + +To disable the server, run: +```bash +npx argocd-mcp@latest claude disable +``` ### Self-signed Certificates diff --git a/src/cmd/cmd.ts b/src/cmd/cmd.ts index 4d6b2c2..69f2458 100644 --- a/src/cmd/cmd.ts +++ b/src/cmd/cmd.ts @@ -5,6 +5,8 @@ import { connectHttpTransport, connectSSETransport } from '../server/transport.js'; +import { ClaudeConfigManager } from '../platform/claude/config.js'; +import { VSCodeConfigManager } from '../platform/vscode/config.js'; export const cmd = () => { const exe = yargs(hideBin(process.argv)); @@ -40,5 +42,155 @@ export const cmd = () => { ({ port }) => connectHttpTransport(port) ); - exe.demandCommand().parseSync(); + const validateUrl = (baseUrl?: string) => { + // MCP servers do not have access to local env, it must be set in config + // If flag was not set, fallback to env + if (!baseUrl) { + baseUrl = process.env.ARGOCD_BASE_URL; + if (!baseUrl) { + throw new Error( + 'Argocd baseurl not provided and not in env, please provide it with the --url flag' + ); + } + } + + // Validate url + new URL(baseUrl); + + return baseUrl; + }; + + const validateToken = (apiToken?: string) => { + // MCP servers do not have access to local env, it must be set in config + // If flag was not set, fallback to env + if (!apiToken) { + apiToken = process.env.ARGOCD_API_TOKEN; + if (!apiToken) { + throw new Error( + 'Argocd token not provided and not in env, please provide it with the --token flag' + ); + } + } + + return apiToken; + }; + + exe.command('claude', 'Manage Claude Desktop integration', (yargs) => { + return yargs + .command( + 'enable', + 'Enable ArgoCD MCP server in Claude Desktop', + (yargs) => { + return yargs + .option('url', { + type: 'string', + description: 'ArgoCD base URL (falls back to ARGOCD_BASE_URL env var)' + }) + .option('token', { + type: 'string', + description: 'ArgoCD API token (falls back to ARGOCD_API_TOKEN env var)' + }); + }, + async ({ url, token }) => { + const manager = new ClaudeConfigManager(); + try { + console.log(`Configuration file: ${manager.getConfigPath()}`); + const wasEnabled = await manager.enable(validateUrl(url), validateToken(token)); + if (wasEnabled) { + console.log('✓ ArgoCD MCP server configuration updated in Claude Desktop'); + } else { + console.log('✓ ArgoCD MCP server enabled in Claude Desktop'); + } + } catch (error) { + console.error('Failed to enable ArgoCD MCP server:', (error as Error).message); + process.exit(1); + } + } + ) + .command( + 'disable', + 'Disable ArgoCD MCP server in Claude Desktop', + () => {}, + async () => { + const manager = new ClaudeConfigManager(); + try { + console.log(`Configuration file: ${manager.getConfigPath()}`); + const wasEnabled = await manager.disable(); + if (wasEnabled) { + console.log('✓ ArgoCD MCP server disabled in Claude Desktop'); + } else { + console.log('ArgoCD MCP server was not enabled'); + } + } catch (error) { + console.error('Failed to disable ArgoCD MCP server:', (error as Error).message); + process.exit(1); + } + } + ); + }); + + exe.command('vscode', 'Manage VS Code integration', (yargs) => { + return yargs + .command( + 'enable', + 'Enable ArgoCD MCP server in VS Code', + (yargs) => { + return yargs + .option('workspace', { + type: 'boolean', + description: 'Install in current workspace directory' + }) + .option('url', { + type: 'string', + description: 'ArgoCD base URL (falls back to ARGOCD_BASE_URL env var)' + }) + .option('token', { + type: 'string', + description: 'ArgoCD API token (falls back to ARGOCD_API_TOKEN env var)' + }); + }, + async ({ workspace, url, token }) => { + const manager = new VSCodeConfigManager(workspace); + try { + console.log(`Configuration file: ${manager.getConfigPath()}`); + const wasEnabled = await manager.enable(validateUrl(url), validateToken(token)); + if (wasEnabled) { + console.log('✓ ArgoCD MCP server configuration updated in VS Code'); + } else { + console.log('✓ ArgoCD MCP server enabled in VS Code'); + } + } catch (error) { + console.error('Failed to enable ArgoCD MCP server:', (error as Error).message); + process.exit(1); + } + } + ) + .command( + 'disable', + 'Disable ArgoCD MCP server in VS Code', + (yargs) => { + return yargs.option('workspace', { + type: 'boolean', + description: 'Install in current workspace directory' + }); + }, + async ({ workspace }) => { + const manager = new VSCodeConfigManager(workspace); + try { + console.log(`Configuration file: ${manager.getConfigPath()}`); + const wasEnabled = await manager.disable(); + if (wasEnabled) { + console.log('✓ ArgoCD MCP server disabled in VS Code'); + } else { + console.log('ArgoCD MCP server was not enabled'); + } + } catch (error) { + console.error('Failed to disable ArgoCD MCP server:', (error as Error).message); + process.exit(1); + } + } + ); + }); + + exe.demandCommand().strict().parse(); }; diff --git a/src/platform/base.ts b/src/platform/base.ts new file mode 100644 index 0000000..42cb0a9 --- /dev/null +++ b/src/platform/base.ts @@ -0,0 +1,145 @@ +import { readFile, writeFile, mkdir, copyFile, stat } from 'fs/promises'; +import { dirname, join } from 'path'; + +/** + * Base interface that all MCP config formats must implement. + * This ensures type safety when accessing the servers collection. + */ +export interface MCPConfig { + [serversKey: string]: Record; +} + +const isObject = (obj: unknown): boolean => { + return !!obj && typeof obj === 'object' && !Array.isArray(obj); +}; + +/** + * Abstract base class for managing MCP server configurations across different platforms. + * + * This implementation preserves all unknown properties in the config file to avoid data loss + * when modifying only the server configuration. + * + * @template T - The specific config type for the platform (must extend MCPConfig) + * @template S - The server configuration type for the platform + */ +export abstract class ConfigManager { + protected readonly serverName = 'argocd-mcp-stdio'; + protected abstract configPath: string; + protected abstract getServersKey(): Extract; + protected abstract createServerConfig(baseUrl: string, apiToken: string): S; + + /** + * ReadConfig preserves all existing properties in the config file. + * @returns config casted to type T + */ + async readConfig(): Promise { + try { + const content = await readFile(this.configPath, 'utf-8'); + + // Parse as unknown first to ensure we preserve all properties + const parsed = JSON.parse(content) as unknown; + + if (!isObject(parsed)) { + // Overwrite with object + return {} as T; + } + + return parsed as T; + } catch (error) { + // File does not exist + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return {} as T; + } + + // Invalid JSON + if (error instanceof SyntaxError) { + // Overwrite with object + return {} as T; + } + + throw error; + } + } + + async writeConfig(config: T): Promise { + const dir = dirname(this.configPath); + try { + await mkdir(dir, { recursive: true }); + await writeFile(this.configPath, JSON.stringify(config, null, 2), 'utf-8'); + } catch (error) { + throw new Error(`Failed to write config to ${this.configPath}: ${(error as Error).message}`); + } + } + + /** + * Enable the server configuration. + * @param baseUrl - Optional ArgoCD base URL + * @param apiToken - Optional ArgoCD API token + * @returns true if the server was already enabled, false if it was newly enabled + */ + async enable(baseUrl: string, apiToken: string): Promise { + const config = await this.readConfig(); + const serversKey = this.getServersKey(); + + // Ensure servers object exists + const obj = config[serversKey]; + if (!isObject(obj)) { + // Overwrite with object + (config[serversKey] as Record) = {}; + } + + const servers = config[serversKey] as Record; + const wasEnabled = this.serverName in servers; + const serverConfig = this.createServerConfig(baseUrl, apiToken); + servers[this.serverName] = serverConfig; + await this.createBackup(); + await this.writeConfig(config); + return wasEnabled; + } + + async createBackup(): Promise { + try { + await stat(this.configPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return; + } + throw error; + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const dir = dirname(this.configPath); + const backupPath = join(dir, `mcp.backup.${timestamp}.json`); + + await copyFile(this.configPath, backupPath); + } + + /** + * Disable the server configuration. + * @returns true if the server was enabled and has been disabled, false if it was not enabled + */ + async disable(): Promise { + const config = await this.readConfig(); + const serversKey = this.getServersKey(); + + const obj = config[serversKey]; + if (!isObject(obj)) { + // Nothing to disable if servers object doesn't exist + return false; + } + + const servers = config[serversKey] as Record; + const wasEnabled = this.serverName in servers; + + if (wasEnabled) { + delete servers[this.serverName]; + await this.writeConfig(config); + } + + return wasEnabled; + } + + getConfigPath(): string { + return this.configPath; + } +} diff --git a/src/platform/claude/config.ts b/src/platform/claude/config.ts new file mode 100644 index 0000000..ba566da --- /dev/null +++ b/src/platform/claude/config.ts @@ -0,0 +1,63 @@ +import { join } from 'path'; +import { homedir } from 'os'; +import { ConfigManager, MCPConfig } from '../base.js'; + +interface ClaudeServerConfig { + command: string; + args: string[]; + env?: Record; +} + +// Claude-specific key +const serversKey = 'mcpServers'; + +interface ClaudeConfig extends MCPConfig { + [serversKey]: Record; +} + +export class ClaudeConfigManager extends ConfigManager { + protected configPath: string; + + constructor() { + super(); + const home = homedir(); + const platform = process.platform; + + switch (platform) { + case 'darwin': + this.configPath = join( + home, + 'Library', + 'Application Support', + 'Claude', + 'claude_desktop_config.json' + ); + break; + case 'win32': + this.configPath = join(home, 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json'); + break; + case 'linux': + this.configPath = join(home, '.config', 'Claude', 'claude_desktop_config.json'); + break; + default: + throw new Error(`platform not supported: ${platform}`); + } + } + + protected getServersKey() { + return serversKey; + } + + protected createServerConfig(baseUrl: string, apiToken: string): ClaudeServerConfig { + const serverConfig: ClaudeServerConfig = { + command: 'npx', + args: ['argocd-mcp@latest', 'stdio'], + env: { + ARGOCD_BASE_URL: baseUrl, + ARGOCD_API_TOKEN: apiToken + } + }; + + return serverConfig; + } +} diff --git a/src/platform/vscode/config.ts b/src/platform/vscode/config.ts new file mode 100644 index 0000000..895b2d2 --- /dev/null +++ b/src/platform/vscode/config.ts @@ -0,0 +1,71 @@ +import { join } from 'path'; +import { homedir } from 'os'; +import { ConfigManager, MCPConfig } from '../base.js'; + +interface VSCodeServerConfig { + type: string; + command: string; + args: string[]; + env?: Record; +} + +// VSCode-specific key +const serversKey = 'servers'; + +interface VSCodeConfig extends MCPConfig { + [serversKey]: Record; +} + +export class VSCodeConfigManager extends ConfigManager { + protected configPath: string; + + constructor(workspace?: boolean) { + super(); + + if (workspace) { + this.configPath = join(process.cwd(), '.vscode', 'mcp.json'); + } else { + const home = homedir(); + const platform = process.platform; + + switch (platform) { + case 'darwin': + this.configPath = join( + home, + 'Library', + 'Application Support', + 'Code', + 'User', + 'mcp.json' + ); + break; + case 'win32': + this.configPath = join(home, 'AppData', 'Roaming', 'Code', 'User', 'mcp.json'); + break; + case 'linux': + this.configPath = join(home, '.config', 'Code', 'User', 'mcp.json'); + break; + default: + throw new Error(`platform not supported: ${platform}`); + } + } + } + + protected getServersKey() { + return serversKey; + } + + protected createServerConfig(baseUrl: string, apiToken: string): VSCodeServerConfig { + const serverConfig: VSCodeServerConfig = { + type: 'stdio', + command: 'npx', + args: ['argocd-mcp@latest', 'stdio'], + env: { + ARGOCD_BASE_URL: baseUrl, + ARGOCD_API_TOKEN: apiToken + } + }; + + return serverConfig; + } +}