Skip to content

Commit 50623ae

Browse files
committed
feat: add 'enable'/'disable' commands for Claude and VSCode
Signed-off-by: nick powell <[email protected]>
1 parent a1971c7 commit 50623ae

File tree

4 files changed

+414
-1
lines changed

4 files changed

+414
-1
lines changed

src/cmd/cmd.ts

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
connectHttpTransport,
66
connectSSETransport
77
} from '../server/transport.js';
8+
import { ClaudeConfigManager } from '../platform/claude/config.js';
9+
import { VSCodeConfigManager } from '../platform/vscode/config.js';
810

911
export const cmd = () => {
1012
const exe = yargs(hideBin(process.argv));
@@ -40,5 +42,155 @@ export const cmd = () => {
4042
({ port }) => connectHttpTransport(port)
4143
);
4244

43-
exe.demandCommand().parseSync();
45+
const validateUrl = (baseUrl?: string) => {
46+
// MCP servers do not have access to local env, it must be set in config
47+
// If flag was not set, fallback to env
48+
if (!baseUrl) {
49+
baseUrl = process.env.ARGOCD_BASE_URL;
50+
if (!baseUrl) {
51+
throw new Error(
52+
'Argocd baseurl not provided and not in env, please provide it with the --url flag'
53+
);
54+
}
55+
}
56+
57+
// Validate url
58+
new URL(baseUrl);
59+
60+
return baseUrl;
61+
};
62+
63+
const validateToken = (apiToken?: string) => {
64+
// MCP servers do not have access to local env, it must be set in config
65+
// If flag was not set, fallback to env
66+
if (!apiToken) {
67+
apiToken = process.env.ARGOCD_API_TOKEN;
68+
if (!apiToken) {
69+
throw new Error(
70+
'Argocd token not provided and not in env, please provide it with the --token flag'
71+
);
72+
}
73+
}
74+
75+
return apiToken;
76+
};
77+
78+
exe.command('claude', 'Manage Claude Desktop integration', (yargs) => {
79+
return yargs
80+
.command(
81+
'enable',
82+
'Enable ArgoCD MCP server in Claude Desktop',
83+
(yargs) => {
84+
return yargs
85+
.option('url', {
86+
type: 'string',
87+
description: 'ArgoCD base URL (falls back to ARGOCD_BASE_URL env var)'
88+
})
89+
.option('token', {
90+
type: 'string',
91+
description: 'ArgoCD API token (falls back to ARGOCD_API_TOKEN env var)'
92+
});
93+
},
94+
async ({ url, token }) => {
95+
const manager = new ClaudeConfigManager();
96+
try {
97+
console.log(`Configuration file: ${manager.getConfigPath()}`);
98+
const wasEnabled = await manager.enable(validateUrl(url), validateToken(token));
99+
if (wasEnabled) {
100+
console.log('✓ ArgoCD MCP server configuration updated in Claude Desktop');
101+
} else {
102+
console.log('✓ ArgoCD MCP server enabled in Claude Desktop');
103+
}
104+
} catch (error) {
105+
console.error('Failed to enable ArgoCD MCP server:', (error as Error).message);
106+
process.exit(1);
107+
}
108+
}
109+
)
110+
.command(
111+
'disable',
112+
'Disable ArgoCD MCP server in Claude Desktop',
113+
() => {},
114+
async () => {
115+
const manager = new ClaudeConfigManager();
116+
try {
117+
console.log(`Configuration file: ${manager.getConfigPath()}`);
118+
const wasEnabled = await manager.disable();
119+
if (wasEnabled) {
120+
console.log('✓ ArgoCD MCP server disabled in Claude Desktop');
121+
} else {
122+
console.log('ArgoCD MCP server was not enabled');
123+
}
124+
} catch (error) {
125+
console.error('Failed to disable ArgoCD MCP server:', (error as Error).message);
126+
process.exit(1);
127+
}
128+
}
129+
);
130+
});
131+
132+
exe.command('vscode', 'Manage VS Code integration', (yargs) => {
133+
return yargs
134+
.command(
135+
'enable',
136+
'Enable ArgoCD MCP server in VS Code',
137+
(yargs) => {
138+
return yargs
139+
.option('workspace', {
140+
type: 'boolean',
141+
description: 'Install in current workspace directory'
142+
})
143+
.option('url', {
144+
type: 'string',
145+
description: 'ArgoCD base URL (falls back to ARGOCD_BASE_URL env var)'
146+
})
147+
.option('token', {
148+
type: 'string',
149+
description: 'ArgoCD API token (falls back to ARGOCD_API_TOKEN env var)'
150+
});
151+
},
152+
async ({ workspace, url, token }) => {
153+
const manager = new VSCodeConfigManager(workspace);
154+
try {
155+
console.log(`Configuration file: ${manager.getConfigPath()}`);
156+
const wasEnabled = await manager.enable(validateUrl(url), validateToken(token));
157+
if (wasEnabled) {
158+
console.log('✓ ArgoCD MCP server configuration updated in VS Code');
159+
} else {
160+
console.log('✓ ArgoCD MCP server enabled in VS Code');
161+
}
162+
} catch (error) {
163+
console.error('Failed to enable ArgoCD MCP server:', (error as Error).message);
164+
process.exit(1);
165+
}
166+
}
167+
)
168+
.command(
169+
'disable',
170+
'Disable ArgoCD MCP server in VS Code',
171+
(yargs) => {
172+
return yargs.option('workspace', {
173+
type: 'boolean',
174+
description: 'Install in current workspace directory'
175+
});
176+
},
177+
async ({ workspace }) => {
178+
const manager = new VSCodeConfigManager(workspace);
179+
try {
180+
console.log(`Configuration file: ${manager.getConfigPath()}`);
181+
const wasEnabled = await manager.disable();
182+
if (wasEnabled) {
183+
console.log('✓ ArgoCD MCP server disabled in VS Code');
184+
} else {
185+
console.log('ArgoCD MCP server was not enabled');
186+
}
187+
} catch (error) {
188+
console.error('Failed to disable ArgoCD MCP server:', (error as Error).message);
189+
process.exit(1);
190+
}
191+
}
192+
);
193+
});
194+
195+
exe.demandCommand().strict().parse();
44196
};

src/platform/base.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { readFile, writeFile, mkdir } from 'fs/promises';
2+
import { dirname } from 'path';
3+
4+
/**
5+
* Base interface that all MCP config formats must implement.
6+
* This ensures type safety when accessing the servers collection.
7+
*/
8+
export interface MCPConfig {
9+
[serversKey: string]: Record<string, unknown>;
10+
}
11+
12+
const isObject = (obj: unknown): boolean => {
13+
return !!obj && typeof obj === 'object' && !Array.isArray(obj);
14+
};
15+
16+
/**
17+
* Abstract base class for managing MCP server configurations across different platforms.
18+
*
19+
* This implementation preserves all unknown properties in the config file to avoid data loss
20+
* when modifying only the server configuration.
21+
*
22+
* @template T - The specific config type for the platform (must extend MCPConfig)
23+
* @template S - The server configuration type for the platform
24+
*/
25+
export abstract class ConfigManager<T extends MCPConfig, S = unknown> {
26+
protected readonly serverName = 'argocd-mcp-stdio';
27+
protected abstract configPath: string;
28+
protected abstract getServersKey(): Extract<keyof T, string>;
29+
protected abstract createServerConfig(baseUrl: string, apiToken: string): S;
30+
31+
/**
32+
* ReadConfig preserves all existing properties in the config file.
33+
* @returns config casted to type T
34+
*/
35+
async readConfig(): Promise<T> {
36+
try {
37+
const content = await readFile(this.configPath, 'utf-8');
38+
39+
// Parse as unknown first to ensure we preserve all properties
40+
const parsed = JSON.parse(content) as unknown;
41+
42+
if (!isObject(parsed)) {
43+
// Overwrite with object
44+
return {} as T;
45+
}
46+
47+
return parsed as T;
48+
} catch (error) {
49+
// File does not exist
50+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
51+
return {} as T;
52+
}
53+
54+
// Invalid JSON
55+
if (error instanceof SyntaxError) {
56+
// Overwrite with object
57+
return {} as T;
58+
}
59+
60+
throw error;
61+
}
62+
}
63+
64+
async writeConfig(config: T): Promise<void> {
65+
const dir = dirname(this.configPath);
66+
try {
67+
await mkdir(dir, { recursive: true });
68+
await writeFile(this.configPath, JSON.stringify(config, null, 2), 'utf-8');
69+
} catch (error) {
70+
throw new Error(`Failed to write config to ${this.configPath}: ${(error as Error).message}`);
71+
}
72+
}
73+
74+
/**
75+
* Enable the server configuration.
76+
* @param baseUrl - Optional ArgoCD base URL
77+
* @param apiToken - Optional ArgoCD API token
78+
* @returns true if the server was already enabled, false if it was newly enabled
79+
*/
80+
async enable(baseUrl: string, apiToken: string): Promise<boolean> {
81+
const config = await this.readConfig();
82+
const serversKey = this.getServersKey();
83+
84+
// Ensure servers object exists
85+
const obj = config[serversKey];
86+
if (!isObject(obj)) {
87+
// Overwrite with object
88+
(config[serversKey] as Record<string, S>) = {};
89+
}
90+
91+
const servers = config[serversKey] as Record<string, S>;
92+
const wasEnabled = this.serverName in servers;
93+
const serverConfig = this.createServerConfig(baseUrl, apiToken);
94+
servers[this.serverName] = serverConfig;
95+
await this.writeConfig(config);
96+
return wasEnabled;
97+
}
98+
99+
/**
100+
* Disable the server configuration.
101+
* @returns true if the server was enabled and has been disabled, false if it was not enabled
102+
*/
103+
async disable(): Promise<boolean> {
104+
const config = await this.readConfig();
105+
const serversKey = this.getServersKey();
106+
107+
const obj = config[serversKey];
108+
if (!isObject(obj)) {
109+
// Nothing to disable if servers object doesn't exist
110+
return false;
111+
}
112+
113+
const servers = config[serversKey] as Record<string, S>;
114+
const wasEnabled = this.serverName in servers;
115+
116+
if (wasEnabled) {
117+
delete servers[this.serverName];
118+
await this.writeConfig(config);
119+
}
120+
121+
return wasEnabled;
122+
}
123+
124+
getConfigPath(): string {
125+
return this.configPath;
126+
}
127+
}

src/platform/claude/config.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { join } from 'path';
2+
import { homedir } from 'os';
3+
import { ConfigManager, MCPConfig } from '../base.js';
4+
5+
interface ClaudeServerConfig {
6+
command: string;
7+
args: string[];
8+
env?: Record<string, string>;
9+
}
10+
11+
// Claude-specific key
12+
const serversKey = 'mcpServers';
13+
14+
interface ClaudeConfig extends MCPConfig {
15+
[serversKey]: Record<string, ClaudeServerConfig>;
16+
}
17+
18+
export class ClaudeConfigManager extends ConfigManager<ClaudeConfig, ClaudeServerConfig> {
19+
protected configPath: string;
20+
21+
constructor() {
22+
super();
23+
const home = homedir();
24+
const platform = process.platform;
25+
26+
switch (platform) {
27+
case 'darwin':
28+
this.configPath = join(
29+
home,
30+
'Library',
31+
'Application Support',
32+
'Claude',
33+
'claude_desktop_config.json'
34+
);
35+
break;
36+
case 'win32':
37+
this.configPath = join(home, 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json');
38+
break;
39+
case 'linux':
40+
this.configPath = join(home, '.config', 'Claude', 'claude_desktop_config.json');
41+
break;
42+
default:
43+
throw new Error(`platform not supported: ${platform}`);
44+
}
45+
}
46+
47+
protected getServersKey() {
48+
return serversKey;
49+
}
50+
51+
protected createServerConfig(baseUrl: string, apiToken: string): ClaudeServerConfig {
52+
const serverConfig: ClaudeServerConfig = {
53+
command: 'npx',
54+
args: ['argocd-mcp@latest', 'stdio'],
55+
env: {
56+
ARGOCD_BASE_URL: baseUrl,
57+
ARGOCD_API_TOKEN: apiToken
58+
}
59+
};
60+
61+
return serverConfig;
62+
}
63+
}

0 commit comments

Comments
 (0)