@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
JavaScript
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`);
}
;