UNPKG

@aashari/mcp-server-aws-sso

Version:

Node.js/TypeScript MCP server for AWS Single Sign-On (SSO). Enables AI systems (LLMs) with tools to initiate SSO login (device auth flow), list accounts/roles, and securely execute AWS CLI commands using temporary credentials. Streamlines AI interaction w

248 lines (247 loc) 11.5 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.executeEc2Command = executeEc2Command; const logger_util_js_1 = require("../utils/logger.util.js"); const vendor_aws_sso_accounts_service_js_1 = require("./vendor.aws.sso.accounts.service.js"); const error_util_js_1 = require("../utils/error.util.js"); const retry_util_js_1 = require("../utils/retry.util.js"); const client_ssm_1 = require("@aws-sdk/client-ssm"); const client_ec2_1 = require("@aws-sdk/client-ec2"); const logger = logger_util_js_1.Logger.forContext('services/vendor.aws.sso.ec2.service.ts'); // Default timeout for polling command completion (in milliseconds) const DEFAULT_COMMAND_TIMEOUT_MS = 20000; // Poll interval (in milliseconds) const POLL_INTERVAL_MS = 1000; /** * Execute a shell command on an EC2 instance via SSM * * @param params Parameters for command execution * @returns Command execution result * @throws Error if the command execution fails */ async function executeEc2Command(params) { const methodLogger = logger.forMethod('executeEc2Command'); methodLogger.debug('Executing EC2 command via SSM', params); // Validate parameters if (!params.instanceId || !params.accountId || !params.roleName || !params.command) { throw new Error('Instance ID, account ID, role name, and command are required'); } try { // Get AWS credentials for the specified account and role const credentials = await (0, vendor_aws_sso_accounts_service_js_1.getAwsCredentials)({ accountId: params.accountId, roleName: params.roleName, forceRefresh: params.forceRefresh, }); methodLogger.debug('Obtained temporary credentials', { accountId: params.accountId, roleName: params.roleName, expiration: credentials.expiration, region: params.region, }); // Create SSM client with credentials const ssmClient = new client_ssm_1.SSMClient({ credentials: { accessKeyId: credentials.accessKeyId, secretAccessKey: credentials.secretAccessKey, sessionToken: credentials.sessionToken, }, region: params.region, // Use the explicitly provided region }); // Create EC2 client with the same credentials to fetch instance details const ec2Client = new client_ec2_1.EC2Client({ credentials: { accessKeyId: credentials.accessKeyId, secretAccessKey: credentials.secretAccessKey, sessionToken: credentials.sessionToken, }, region: params.region, }); // Try to fetch instance name using the instance ID let instanceName; try { methodLogger.debug('Fetching instance details', { instanceId: params.instanceId, }); const describeCommand = new client_ec2_1.DescribeInstancesCommand({ InstanceIds: [params.instanceId], }); const instanceResponse = await (0, retry_util_js_1.withRetry)(() => ec2Client.send(describeCommand), { maxRetries: 2, initialDelayMs: 500, backoffFactor: 2.0, retryCondition: (error) => { // Retry on throttling or temporary errors const errorName = error && typeof error === 'object' && 'name' in error ? String(error.name) : ''; return (errorName === 'ThrottlingException' || errorName === 'InternalServerError' || errorName === 'ServiceUnavailableException'); }, }); // Extract name tag from instance if available const instance = instanceResponse.Reservations?.[0]?.Instances?.[0]; if (instance?.Tags) { const nameTag = instance.Tags.find((tag) => tag.Key === 'Name'); if (nameTag?.Value) { instanceName = nameTag.Value; methodLogger.debug('Found instance name', { instanceName }); } } } catch (error) { // Log but continue - instance name is optional methodLogger.warn('Could not fetch instance name, continuing without it', error); } // Send the command to the instance methodLogger.debug('Sending command to EC2 instance', { instanceId: params.instanceId, command: params.command, }); const sendCommand = new client_ssm_1.SendCommandCommand({ InstanceIds: [params.instanceId], DocumentName: 'AWS-RunShellScript', Parameters: { commands: [params.command], }, }); const sendResult = await (0, retry_util_js_1.withRetry)(() => ssmClient.send(sendCommand), { maxRetries: 3, initialDelayMs: 1000, backoffFactor: 2.0, retryCondition: (error) => { // Retry on throttling or temporary errors const errorName = error && typeof error === 'object' && 'name' in error ? String(error.name) : ''; return (errorName === 'ThrottlingException' || errorName === 'InternalServerError' || errorName === 'ServiceUnavailableException'); }, }); if (!sendResult.Command?.CommandId) { throw (0, error_util_js_1.createApiError)('Failed to send command: No command ID returned'); } const commandId = sendResult.Command.CommandId; methodLogger.debug('Command sent successfully', { commandId }); // Poll for command completion and include instance name in result const commandResult = await pollCommandCompletion(ssmClient, commandId, params.instanceId, params.timeout || DEFAULT_COMMAND_TIMEOUT_MS); // Return result with instance name return { ...commandResult, instanceName, }; } catch (error) { methodLogger.error('Failed to execute EC2 command', error); // Handle specific error cases with more helpful messages if (error && typeof error === 'object' && 'name' in error) { const errorName = String(error.name); if (errorName === 'InvalidInstanceId') { throw (0, error_util_js_1.createApiError)(`Instance ${params.instanceId} not found or not connected to SSM. Ensure the instance is running and has the SSM Agent installed.`, undefined, error); } else if (errorName === 'AccessDeniedException') { throw (0, error_util_js_1.createApiError)(`Access denied. The role "${params.roleName}" does not have permission to execute SSM commands on instance ${params.instanceId}.`, undefined, error); } // Generic error throw (0, error_util_js_1.createApiError)(`Failed to execute command on instance ${params.instanceId}: ${error && typeof error === 'object' && 'message' in error ? String(error.message) : String(error)}`, undefined, error); } // Generic case if error doesn't have a name throw (0, error_util_js_1.createApiError)(`Failed to execute command on instance ${params.instanceId}: ${String(error)}`, undefined, error); } } /** * Polls for the completion status of a command * * @param client SSM client * @param commandId Command ID to poll * @param instanceId Instance ID where the command was executed * @param timeoutMs Timeout in milliseconds * @returns Command execution result * @throws Error if polling fails or times out */ async function pollCommandCompletion(client, commandId, instanceId, timeoutMs) { const pollLogger = logger.forMethod('pollCommandCompletion'); pollLogger.debug('Polling for command completion', { commandId, instanceId, timeoutMs, }); const startTime = Date.now(); let elapsedTime = 0; while (elapsedTime < timeoutMs) { try { const result = await (0, retry_util_js_1.withRetry)(() => client.send(new client_ssm_1.GetCommandInvocationCommand({ CommandId: commandId, InstanceId: instanceId, })), { maxRetries: 3, initialDelayMs: 1000, backoffFactor: 2.0, retryCondition: (error) => { // Retry on throttling or temporary errors const errorName = error && typeof error === 'object' && 'name' in error ? String(error.name) : ''; // Don't retry on InvocationDoesNotExist as this often means // the command has been accepted but not yet propagated in SSM if (errorName === 'InvocationDoesNotExist') { return false; } return (errorName === 'ThrottlingException' || errorName === 'InternalServerError' || errorName === 'ServiceUnavailableException'); }, }); pollLogger.debug('Command status poll result', { commandId, instanceId, status: result.Status, }); // Pending and InProgress statuses mean we need to keep polling if (result.Status === 'Pending' || result.Status === 'InProgress') { await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); elapsedTime = Date.now() - startTime; continue; } // Command has completed (success, failed, etc.) return { output: result.StandardOutputContent || '', status: result.Status || 'Unknown', commandId, instanceId, responseCode: result.ResponseCode || null, }; } catch (error) { // Check if it's an InvocationDoesNotExist error const errorName = error && typeof error === 'object' && 'name' in error ? String(error.name) : ''; if (errorName === 'InvocationDoesNotExist') { // This happens when the command is still being propagated // Wait and then continue polling pollLogger.debug('Command invocation not found yet, waiting...', { commandId, instanceId }); await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); elapsedTime = Date.now() - startTime; continue; } pollLogger.error('Error polling command status', error); throw (0, error_util_js_1.createApiError)(`Failed to get command status: ${error && typeof error === 'object' && 'message' in error ? String(error.message) : String(error)}`, undefined, error); } } // If we get here, we've timed out throw (0, error_util_js_1.createApiError)(`Command execution timed out after ${timeoutMs / 1000} seconds`); }