Files
mcpctl/src/cli/src/commands/config.ts
Michal dcda93d179
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
feat: granular RBAC with resource/operation bindings, users, groups
- Replace admin role with granular roles: view, create, delete, edit, run
- Two binding types: resource bindings (role+resource+optional name) and
  operation bindings (role:run + action like backup, logs, impersonate)
- Name-scoped resource bindings for per-instance access control
- Remove role from project members (all permissions via RBAC)
- Add users, groups, RBAC CRUD endpoints and CLI commands
- describe user/group shows all RBAC access (direct + inherited)
- create rbac supports --subject, --binding, --operation flags
- Backup/restore handles users, groups, RBAC definitions
- mcplocal project-based MCP endpoint discovery
- Full test coverage for all new functionality

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:05:19 +00:00

199 lines
6.8 KiB
TypeScript

import { Command } from 'commander';
import { writeFileSync, readFileSync, existsSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { homedir } from 'node:os';
import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../config/index.js';
import type { McpctlConfig, ConfigLoaderDeps } from '../config/index.js';
import { formatJson, formatYaml } from '../formatters/index.js';
import { saveCredentials, loadCredentials } from '../auth/index.js';
import type { CredentialsDeps, StoredCredentials } from '../auth/index.js';
import type { ApiClient } from '../api-client.js';
interface McpConfig {
mcpServers: Record<string, { command: string; args: string[]; env?: Record<string, string> }>;
}
export interface ConfigCommandDeps {
configDeps: Partial<ConfigLoaderDeps>;
log: (...args: string[]) => void;
}
export interface ConfigApiDeps {
client: ApiClient;
credentialsDeps: Partial<CredentialsDeps>;
log: (...args: string[]) => void;
}
const defaultDeps: ConfigCommandDeps = {
configDeps: {},
log: (...args) => console.log(...args),
};
export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?: ConfigApiDeps): Command {
const { configDeps, log } = { ...defaultDeps, ...deps };
const config = new Command('config').description('Manage mcpctl configuration');
config
.command('view')
.description('Show current configuration')
.option('-o, --output <format>', 'output format (json, yaml)', 'json')
.action((opts: { output: string }) => {
const cfg = loadConfig(configDeps);
const out = opts.output === 'yaml' ? formatYaml(cfg) : formatJson(cfg);
log(out);
});
config
.command('set')
.description('Set a configuration value')
.argument('<key>', 'configuration key (e.g., daemonUrl, outputFormat)')
.argument('<value>', 'value to set')
.action((key: string, value: string) => {
const updates: Record<string, unknown> = {};
// Handle typed conversions
if (key === 'cacheTTLMs') {
updates[key] = parseInt(value, 10);
} else if (key === 'registries') {
updates[key] = value.split(',').map((s) => s.trim());
} else if (key === 'daemonUrl') {
// Backward compat: map daemonUrl to mcplocalUrl
updates['mcplocalUrl'] = value;
} else {
updates[key] = value;
}
const updated = mergeConfig(updates as Partial<McpctlConfig>, configDeps);
saveConfig(updated, configDeps);
log(`Set ${key} = ${value}`);
});
config
.command('path')
.description('Show configuration file path')
.action(() => {
log(getConfigPath(configDeps?.configDir));
});
config
.command('reset')
.description('Reset configuration to defaults')
.action(() => {
saveConfig(DEFAULT_CONFIG, configDeps);
log('Configuration reset to defaults');
});
if (apiDeps) {
const { client, credentialsDeps, log: apiLog } = apiDeps;
config
.command('claude-generate')
.description('Generate .mcp.json from a project configuration')
.requiredOption('--project <name>', 'Project name')
.option('-o, --output <path>', 'Output file path', '.mcp.json')
.option('--merge', 'Merge with existing .mcp.json instead of overwriting')
.option('--stdout', 'Print to stdout instead of writing a file')
.action(async (opts: { project: string; output: string; merge?: boolean; stdout?: boolean }) => {
const mcpConfig = await client.get<McpConfig>(`/api/v1/projects/${opts.project}/mcp-config`);
if (opts.stdout) {
apiLog(JSON.stringify(mcpConfig, null, 2));
return;
}
const outputPath = resolve(opts.output);
let finalConfig = mcpConfig;
if (opts.merge && existsSync(outputPath)) {
try {
const existing = JSON.parse(readFileSync(outputPath, 'utf-8')) as McpConfig;
finalConfig = {
mcpServers: {
...existing.mcpServers,
...mcpConfig.mcpServers,
},
};
} catch {
// If existing file is invalid, just overwrite
}
}
writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n');
const serverCount = Object.keys(finalConfig.mcpServers).length;
apiLog(`Wrote ${outputPath} (${serverCount} server(s))`);
});
config
.command('impersonate')
.description('Impersonate another user or return to original identity')
.argument('[email]', 'Email of user to impersonate')
.option('--quit', 'Stop impersonating and return to original identity')
.action(async (email: string | undefined, opts: { quit?: boolean }) => {
const configDir = credentialsDeps?.configDir ?? join(homedir(), '.mcpctl');
const backupPath = join(configDir, 'credentials-backup');
if (opts.quit) {
if (!existsSync(backupPath)) {
apiLog('No impersonation session to quit');
process.exitCode = 1;
return;
}
const backupRaw = readFileSync(backupPath, 'utf-8');
const backup = JSON.parse(backupRaw) as StoredCredentials;
saveCredentials(backup, credentialsDeps);
// Remove backup file
const { unlinkSync } = await import('node:fs');
unlinkSync(backupPath);
apiLog(`Returned to ${backup.user}`);
return;
}
if (!email) {
apiLog('Email is required when not using --quit');
process.exitCode = 1;
return;
}
// Save current credentials as backup
const currentCreds = loadCredentials(credentialsDeps);
if (!currentCreds) {
apiLog('Not logged in. Run "mcpctl login" first.');
process.exitCode = 1;
return;
}
writeFileSync(backupPath, JSON.stringify(currentCreds, null, 2) + '\n', 'utf-8');
try {
const result = await client.post<{ token: string; user: { email: string } }>(
'/api/v1/auth/impersonate',
{ email },
);
saveCredentials({
token: result.token,
mcpdUrl: currentCreds.mcpdUrl,
user: result.user.email,
}, credentialsDeps);
apiLog(`Impersonating ${result.user.email}. Use 'mcpctl config impersonate --quit' to return.`);
} catch (err) {
// Restore backup on failure
const backup = JSON.parse(readFileSync(backupPath, 'utf-8')) as StoredCredentials;
saveCredentials(backup, credentialsDeps);
const { unlinkSync } = await import('node:fs');
unlinkSync(backupPath);
apiLog(`Impersonate failed: ${(err as Error).message}`);
process.exitCode = 1;
}
});
}
return config;
}