import { Tool } from '@modelcontextprotocol/sdk/types.js';
import { Logger } from '../utils/logger.js';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as yaml from 'js-yaml';
import { getCredential } from '../utils/keyring.js';
import { validateLudusRangeSchema } from './rangeConfig.js';
export interface InsertCredsRangeConfigArgs {
configPath?: string;
credentialMappings?: { [placeholder: string]: string };
outputPath?: string;
user?: string;
help?: boolean;
}
// Base directory for all Ludus MCP operations
const LUDUS_MCP_BASE_DIR = path.join(os.homedir(), '.ludus-mcp');
const RANGE_CONFIG_TEMPLATES_DIR = path.join(LUDUS_MCP_BASE_DIR, 'range-config-templates');
/**
* Resolve a file path for range configurations with smart organization
*/
function resolveConfigPath(filePath: string, user?: string): string {
if (path.isAbsolute(filePath)) {
return filePath;
}
// If it's just a filename (no directory separators)
if (path.dirname(filePath) === '.') {
if (user) {
// User specified: put in user-specific subdirectory under range-config-templates
const userTemplatesDir = path.join(RANGE_CONFIG_TEMPLATES_DIR, user);
return path.join(userTemplatesDir, filePath);
} else {
// No user specified: put in base range-config-templates directory
return path.join(RANGE_CONFIG_TEMPLATES_DIR, filePath);
}
}
// If it's a relative path with directories, resolve relative to range-config-templates/
return path.resolve(RANGE_CONFIG_TEMPLATES_DIR, filePath);
}
/**
* Securely inject credentials into range config
*/
async function injectCredentialsIntoConfig(
configContent: string,
credentialMappings: { [placeholder: string]: string },
logger: Logger
): Promise<{ success: boolean; content?: string; errors?: string[]; missingCreds?: string[] }> {
let processedContent = configContent;
const errors: string[] = [];
const missingCreds: string[] = [];
logger.info('Starting credential injection', {
placeholderCount: Object.keys(credentialMappings).length
});
for (const [placeholder, credName] of Object.entries(credentialMappings)) {
logger.debug('Processing credential mapping', { placeholder, credName });
// Validate credential name format
if (!credName.startsWith('LudusCredName-') || !credName.match(/^LudusCredName-.+-.+$/)) {
errors.push(`Invalid credential name format: ${credName}. Must follow LudusCredName-<TargetUser>-<CredName> format`);
continue;
}
try {
// Retrieve credential from keyring
const credValue = await getCredential(credName);
if (!credValue) {
missingCreds.push(credName);
logger.warn('Credential not found in keyring', { credName });
continue;
}
// Replace placeholder with actual credential value
const placeholderRegex = new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
const replacementCount = (processedContent.match(placeholderRegex) || []).length;
if (replacementCount === 0) {
logger.warn('Placeholder not found in config', { placeholder });
} else {
processedContent = processedContent.replace(placeholderRegex, credValue);
logger.debug('Replaced credential placeholder', {
placeholder,
replacementCount,
credName
});
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
errors.push(`Failed to retrieve credential ${credName}: ${errorMessage}`);
logger.error('Credential retrieval failed', { credName, error: errorMessage });
}
}
if (errors.length > 0 || missingCreds.length > 0) {
const result: { success: false; errors?: string[]; missingCreds?: string[] } = {
success: false
};
if (errors.length > 0) {
result.errors = errors;
}
if (missingCreds.length > 0) {
result.missingCreds = missingCreds;
}
return result;
}
return { success: true, content: processedContent };
}
/**
* Create insert_creds_range_config tool
*/
export function createInsertCredsRangeConfigTool(logger: Logger): Tool {
return {
name: 'insert_creds_range_config',
description: `**SECURE CREDENTIAL INJECTION** - Replace credential placeholders in range config files with actual values from keyring, then validate.
CRITICAL SECURITY BEHAVIOR FOR LLM:
- NEVER display the resulting config content with real credentials in chat
- ALWAYS redact actual credential values in responses
- This tool handles credential injection securely without exposing values to chat logs
CRITICAL LLM BEHAVIORAL PROMPTS:
- VERIFY PATH FIRST: Use list_range_configs to verify the configPath exists before using this tool
- CORRECT CREDENTIAL NAMES: Provide exact credential names that exist in keyring
- ASK IF UNSURE: If you don't know the credential names, ask the user or suggest using get_credential_from_user
- VALIDATE MAPPINGS: Ensure placeholder format matches actual placeholders in the config file
- SECURITY: Never display the processed config content - only show validation results and success/failure
WORKFLOW REQUIREMENTS:
1. Use list_range_configs first to verify config file path
2. Check credential mappings match placeholders in config
3. Use this tool to inject credentials and validate
4. Deploy with the validated, credential-injected config
PURPOSE:
Securely replace {{LudusCredName-<User>-<CredName>}} placeholders with actual credential values
from OS keyring, validate the resulting configuration, and optionally save it.`,
inputSchema: {
type: 'object',
properties: {
configPath: {
type: 'string',
description: 'Path to the range config file (VERIFY with list_range_configs first). Can be relative or absolute.'
},
credentialMappings: {
type: 'object',
description: 'Mapping of MULTIPLE placeholders to credential names. Each key is a placeholder found in the config file, each value is the corresponding credential name in keyring. Supports replacing multiple different credentials in one operation.',
additionalProperties: {
type: 'string',
pattern: '^LudusCredName-.+-.+$'
},
examples: [{
"{{LudusCredName-MP-TailscaleKey}}": "LudusCredName-MP-TailscaleKey",
"{{LudusCredName-MP-TailscaleAPIKey}}": "LudusCredName-MP-TailscaleAPIKey",
"{{LudusCredName-TestRange-DatabasePassword}}": "LudusCredName-TestRange-DatabasePassword",
"{{LudusCredName-Admin-ServiceToken}}": "LudusCredName-Admin-ServiceToken"
}]
},
outputPath: {
type: 'string',
description: 'Optional: Where to save the credential-injected config. If not provided, creates temporary file for validation only.'
},
validateOnly: {
type: 'boolean',
description: 'If true (default), only validates the credential-injected config without saving. If false, saves to outputPath.',
default: true
},
user: {
type: 'string',
description: 'User context for path resolution (optional). Used when configPath is relative.'
}
},
required: ['configPath', 'credentialMappings']
}
};
}
/**
* Handle insert_creds_range_config tool calls
*/
export async function handleInsertCredsRangeConfig(
args: InsertCredsRangeConfigArgs,
logger: Logger
): Promise<any> {
const { configPath, credentialMappings, outputPath, user, help } = args;
// Handle help-only mode
if (help) {
return {
success: true,
help: true,
tool: 'insert_creds_range_config',
description: 'Replace credential placeholders in range config with actual values from keyring',
usage: {
purpose: 'Securely inject credentials into Ludus range configuration files',
workflow: [
'1. Use list_range_configs to verify configPath exists',
'2. Identify placeholders in config (e.g., {{LudusCredName-MP-TailscaleKey}})',
'3. Map each placeholder to its credential name in credentialMappings',
'4. Credentials must exist in keyring (use get_credential_from_user first if needed)',
'5. Tool validates and optionally saves the processed config'
],
requiredParameters: {
configPath: 'Path to range configuration file (relative to ~/.ludus-mcp/range-config-templates/)',
credentialMappings: 'Object mapping placeholder strings to credential names from keyring'
},
optionalParameters: {
outputPath: 'Where to save processed config (if omitted, overwrites original file)',
user: 'User context for path resolution'
},
exampleUsage: {
configPath: 'MP/simple-ad-tailscale-lab.yml',
credentialMappings: {
'{{LudusCredName-MP-TailscaleKey}}': 'LudusCredName-MP-TailscaleKey',
'{{LudusCredName-MP-AdminPass}}': 'LudusCredName-MP-AdminPass'
}
},
placeholderFormat: 'Placeholders must be in format: {{LudusCredName-<User>-<CredName>}}',
credentialFormat: 'Credential names must follow: LudusCredName-<TargetUser>-<CredName>',
pathResolution: {
relativePaths: 'Resolved relative to ~/.ludus-mcp/range-config-templates/',
absolutePaths: 'Used as-is',
userSpecific: 'If user provided, creates user-specific subdirectory'
},
security: [
'Actual credential values are never exposed in responses',
'Credentials are retrieved securely from OS keyring',
'Processed configs are validated against Ludus schema'
]
}
};
}
// Validate required parameters when not in help mode
if (!configPath || !credentialMappings) {
return {
success: false,
error: 'Missing required parameters',
message: 'configPath and credentialMappings are required. Use help: true for usage information.',
requiredParameters: ['configPath', 'credentialMappings']
};
}
logger.info('Handling insert_creds_range_config request', {
configPath,
credentialCount: Object.keys(credentialMappings).length,
user
});
try {
// Resolve and validate config path
const resolvedConfigPath = resolveConfigPath(configPath, user);
if (!fs.existsSync(resolvedConfigPath)) {
return {
success: false,
error: 'Config file not found',
configPath: resolvedConfigPath,
originalPath: configPath,
message: `Configuration file not found: ${resolvedConfigPath}`,
suggestion: 'Use list_range_configs to verify the correct file path'
};
}
// Read config file
const configContent = fs.readFileSync(resolvedConfigPath, 'utf8');
// Validate credential mappings
const validationErrors: string[] = [];
for (const [placeholder, credName] of Object.entries(credentialMappings)) {
if (!configContent.includes(placeholder)) {
validationErrors.push(`Placeholder "${placeholder}" not found in config file`);
}
if (!credName.match(/^LudusCredName-.+-.+$/)) {
validationErrors.push(`Invalid credential name format: "${credName}"`);
}
}
if (validationErrors.length > 0) {
return {
success: false,
error: 'Validation failed',
validationErrors,
configPath: resolvedConfigPath,
message: 'Credential mapping validation failed'
};
}
// Inject credentials
const injectionResult = await injectCredentialsIntoConfig(
configContent,
credentialMappings,
logger
);
if (!injectionResult.success) {
return {
success: false,
error: 'Credential injection failed',
errors: injectionResult.errors,
missingCredentials: injectionResult.missingCreds,
configPath: resolvedConfigPath,
message: 'Failed to inject credentials into config',
suggestions: injectionResult.missingCreds?.length ? [
'Missing credentials need to be stored first using get_credential_from_user',
'Verify credential names match exactly what was stored',
'Check OS credential manager for stored credentials'
] : undefined
};
}
// Validate the credential-injected YAML
let parsedConfig;
try {
parsedConfig = yaml.load(injectionResult.content!);
} catch (error) {
return {
success: false,
error: 'YAML parsing failed after credential injection',
yamlError: error instanceof Error ? error.message : String(error),
configPath: resolvedConfigPath,
message: 'The credential-injected config has invalid YAML syntax'
};
}
// Validate against Ludus schema
const schemaValidation = await validateLudusRangeSchema(parsedConfig, logger);
// Handle output - always save the credential-injected config
const targetPath = outputPath ? resolveConfigPath(outputPath, user) : resolvedConfigPath;
const outputDir = path.dirname(targetPath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Write the credential-injected config
fs.writeFileSync(targetPath, injectionResult.content!, 'utf8');
const finalOutputPath = targetPath;
logger.info('Credential-injected config saved', {
outputPath: targetPath,
overwroteOriginal: !outputPath
});
// Prepare response (with credential redaction)
const response = {
success: true,
configPath: resolvedConfigPath,
originalPath: configPath,
credentialsInjected: Object.keys(credentialMappings).length,
validation: schemaValidation,
outputPath: finalOutputPath,
message: `Credential injection completed and config saved to ${finalOutputPath}`,
overwroteOriginal: !outputPath,
// Security: Never expose the actual credential-injected content
securityNote: 'Actual credential values have been redacted from this response for security',
nextSteps: [
'Credentials have been injected and file saved',
'Use set_range_config to make this the active configuration',
'Use deploy_range to deploy with real credentials'
]
};
return response;
} catch (error: any) {
logger.error('insert_creds_range_config failed', {
configPath,
error: error.message
});
return {
success: false,
error: error.message,
configPath,
message: `Failed to process credential injection: ${error.message}`
};
}
}