Files
CxIDE/Agent/Tools/AWSTools.swift
T
cx-git-agent 1af1db7ffc Add comprehensive AWS integration (S3, Route53, CloudFront, Lambda, ACM) with 72 new tests
- AWSService: SigV4 request signing, XML parser, credential management
- AWSS3Service: bucket/object CRUD, website hosting, deploy pipeline
- AWSRoute53Service: hosted zones, DNS records, S3/CloudFront aliases
- AWSCloudFrontService: distributions, cache invalidation, ACM certs
- AWSLambdaService: functions, invoke, zip deploy
- AWSTools: 15 MCP agent tools for all AWS operations
- CredentialStore: AWS credential convenience properties
- AWSAndWebsiteTests: 72 tests covering models, XML parsing, errors,
  credentials, templates, deploy providers (165 total tests passing)
2026-04-21 19:40:51 -05:00

688 lines
33 KiB
Swift

// AWSTools.swift
// CxIDE Agent AWS integration tools for S3, CloudFront, Route 53, Lambda, and ACM
//
// 14 tools: aws_configure, aws_s3_list, aws_s3_upload, aws_s3_deploy,
// aws_s3_download, aws_s3_delete, aws_cloudfront_list,
// aws_cloudfront_create, aws_cloudfront_invalidate,
// aws_route53_zones, aws_route53_records, aws_route53_set,
// aws_lambda_list, aws_lambda_invoke
import Foundation
enum AWSTools {
static func register(on server: MCPServer, config: AgentConfig, memory: AgentMemory) {
// aws_configure
server.registerTool(
"aws_configure",
description: "Configure AWS credentials and default region. Credentials are AES-256 encrypted locally.",
inputSchema: [
"type": "object",
"required": ["action"],
"properties": [
"action": ["type": "string", "description": "status, set, or set-region."],
"access_key_id": ["type": "string", "description": "AWS Access Key ID (for set)."],
"secret_access_key": ["type": "string", "description": "AWS Secret Access Key (for set)."],
"region": ["type": "string", "description": "AWS region (default: us-east-1)."],
] as [String: Any],
] as [String: Any]
) { args in
let action = args["action"] as? String ?? "status"
let store = CredentialStore.shared
let wsDir = URL(fileURLWithPath: config.workspaceRoot)
try? store.load(from: wsDir)
switch action.lowercased() {
case "status":
let aws = AWSService.shared
let hasKey = aws.accessKeyId != nil
let hasSecret = aws.secretAccessKey != nil
let region = aws.defaultRegion
var output = "AWS Configuration:\n"
output += " Access Key: \(hasKey ? "✅ Configured (\(aws.accessKeyId?.prefix(8) ?? "")...)" : "❌ Not set")\n"
output += " Secret Key: \(hasSecret ? "✅ Configured" : "❌ Not set")\n"
output += " Region: \(region)\n"
output += " Authenticated: \(aws.isAuthenticated ? "✅ Yes" : "❌ No")\n"
return ok(output)
case "set":
guard let keyId = args["access_key_id"] as? String, !keyId.isEmpty,
let secret = args["secret_access_key"] as? String, !secret.isEmpty else {
return err("access_key_id and secret_access_key required")
}
let region = args["region"] as? String ?? "us-east-1"
AWSService.shared.setCredentials(accessKeyId: keyId, secretAccessKey: secret, region: region)
do {
try store.save(to: wsDir)
return ok("AWS credentials saved (encrypted). Region: \(region)")
} catch {
return err("Failed to save: \(error.localizedDescription)")
}
case "set-region":
let region = args["region"] as? String ?? "us-east-1"
store.set("AWS_DEFAULT_REGION", value: region)
do {
try store.save(to: wsDir)
return ok("AWS region set to: \(region)")
} catch {
return err("Failed to save: \(error.localizedDescription)")
}
default:
return err("Unknown action '\(action)'. Available: status, set, set-region")
}
}
// aws_s3_list
server.registerTool(
"aws_s3_list",
description: "List S3 buckets or objects in a bucket.",
inputSchema: [
"type": "object",
"properties": [
"bucket": ["type": "string", "description": "Bucket name (omit to list all buckets)."],
"prefix": ["type": "string", "description": "Object key prefix filter."],
"region": ["type": "string", "description": "AWS region."],
] as [String: Any],
] as [String: Any],
annotations: ToolAnnotations(readOnlyHint: true)
) { args in
let s3 = AWSS3Service.shared
do {
if let bucket = args["bucket"] as? String, !bucket.isEmpty {
let prefix = args["prefix"] as? String
let region = args["region"] as? String
let objects = try await s3.listObjects(bucket: bucket, prefix: prefix, region: region)
if objects.isEmpty {
return ok("Bucket '\(bucket)' is empty\(prefix != nil ? " (prefix: \(prefix!))" : "")")
}
var output = "Objects in s3://\(bucket)\(prefix != nil ? "/\(prefix!)" : "") (\(objects.count)):\n\n"
output += String(format: "%-50s %10s %s\n", "Key", "Size", "Last Modified")
output += String(repeating: "-", count: 85) + "\n"
for obj in objects {
output += String(format: "%-50s %10s %s\n",
String(obj.key.prefix(50)), obj.formattedSize, obj.lastModified ?? "")
}
return ok(output)
} else {
let buckets = try await s3.listBuckets()
if buckets.isEmpty {
return ok("No S3 buckets found in this account.")
}
var output = "S3 Buckets (\(buckets.count)):\n\n"
for b in buckets {
output += "\(b.name)"
if let date = b.creationDate { output += " (created: \(date))" }
output += "\n"
}
return ok(output)
}
} catch {
return err(error.localizedDescription)
}
}
// aws_s3_upload
server.registerTool(
"aws_s3_upload",
description: "Upload a file to S3.",
inputSchema: [
"type": "object",
"required": ["bucket", "file"],
"properties": [
"bucket": ["type": "string", "description": "S3 bucket name."],
"file": ["type": "string", "description": "Local file path."],
"key": ["type": "string", "description": "S3 object key (default: filename)."],
"region": ["type": "string", "description": "AWS region."],
] as [String: Any],
] as [String: Any]
) { args in
guard let bucket = args["bucket"] as? String,
let filePath = args["file"] as? String,
let resolved = config.resolvePath(filePath) else {
return err("bucket and file required")
}
let fileURL = URL(fileURLWithPath: resolved)
let key = args["key"] as? String ?? fileURL.lastPathComponent
let region = args["region"] as? String
do {
let data = try Data(contentsOf: fileURL)
try await AWSS3Service.shared.putObject(bucket: bucket, key: key, data: data, region: region)
return ok("Uploaded \(fileURL.lastPathComponent) → s3://\(bucket)/\(key) (\(data.count) bytes)")
} catch {
return err(error.localizedDescription)
}
}
// aws_s3_deploy
server.registerTool(
"aws_s3_deploy",
description: "Deploy a website directory to S3 with static hosting enabled. Creates the bucket if needed.",
inputSchema: [
"type": "object",
"required": ["directory", "bucket"],
"properties": [
"directory": ["type": "string", "description": "Local website directory."],
"bucket": ["type": "string", "description": "S3 bucket name (will be created if needed)."],
"region": ["type": "string", "description": "AWS region (default: us-east-1)."],
"cloudfront": ["type": "boolean", "description": "Also create a CloudFront distribution (default: false)."],
"domain": ["type": "string", "description": "Custom domain (requires Route 53 hosted zone)."],
] as [String: Any],
] as [String: Any]
) { args in
guard let dirPath = args["directory"] as? String,
let resolved = config.resolvePath(dirPath),
let bucket = args["bucket"] as? String else {
return err("directory and bucket required")
}
let region = args["region"] as? String
let useCF = args["cloudfront"] as? Bool ?? false
let domain = args["domain"] as? String
do {
let result = try await AWSS3Service.shared.deployWebsite(
directory: URL(fileURLWithPath: resolved),
bucket: bucket,
region: region
)
var output = "S3 Deployment \(result.success ? "Succeeded" : "Completed with errors")\n\n"
output += " Bucket: s3://\(result.bucket)\n"
output += " Region: \(result.region)\n"
output += " Files uploaded: \(result.uploadedFiles.count)\n"
output += " Duration: \(String(format: "%.1f", result.duration))s\n"
if let url = result.websiteURL {
output += " Website URL: \(url)\n"
}
if !result.errors.isEmpty {
output += "\n Errors:\n"
for e in result.errors { output += "\(e)\n" }
}
// Create CloudFront distribution if requested
if useCF {
let cf = AWSCloudFrontService.shared
let aliases = domain != nil ? [domain!] : []
let dist = try await cf.createDistributionForS3(
bucket: bucket, region: result.region, aliases: aliases
)
output += "\n CloudFront Distribution:\n"
output += " ID: \(dist.id)\n"
output += " Domain: https://\(dist.domainName)\n"
output += " Status: \(dist.status) (may take 5-10 min)\n"
// Point Route 53 if domain specified
if let domain = domain {
let r53 = AWSRoute53Service.shared
if let zone = try await r53.findHostedZone(domain: domain) {
try await r53.pointToCloudFront(
zoneId: zone.id, domain: domain,
distributionDomain: dist.domainName
)
output += " Route 53: \(domain)\(dist.domainName)\n"
}
}
}
return ok(output)
} catch {
return err(error.localizedDescription)
}
}
// aws_s3_download
server.registerTool(
"aws_s3_download",
description: "Download an object from S3.",
inputSchema: [
"type": "object",
"required": ["bucket", "key"],
"properties": [
"bucket": ["type": "string", "description": "S3 bucket name."],
"key": ["type": "string", "description": "S3 object key."],
"output": ["type": "string", "description": "Local output path (default: current directory + filename)."],
"region": ["type": "string", "description": "AWS region."],
] as [String: Any],
] as [String: Any]
) { args in
guard let bucket = args["bucket"] as? String,
let key = args["key"] as? String else {
return err("bucket and key required")
}
let region = args["region"] as? String
let outputPath: String
if let out = args["output"] as? String, let resolved = config.resolvePath(out) {
outputPath = resolved
} else {
let filename = (key as NSString).lastPathComponent
outputPath = config.workspaceRoot + "/" + filename
}
do {
let data = try await AWSS3Service.shared.getObject(bucket: bucket, key: key, region: region)
try data.write(to: URL(fileURLWithPath: outputPath))
return ok("Downloaded s3://\(bucket)/\(key)\(outputPath) (\(data.count) bytes)")
} catch {
return err(error.localizedDescription)
}
}
// aws_s3_delete
server.registerTool(
"aws_s3_delete",
description: "Delete an object from S3.",
inputSchema: [
"type": "object",
"required": ["bucket", "key"],
"properties": [
"bucket": ["type": "string", "description": "S3 bucket name."],
"key": ["type": "string", "description": "S3 object key to delete."],
"region": ["type": "string", "description": "AWS region."],
] as [String: Any],
] as [String: Any]
) { args in
guard let bucket = args["bucket"] as? String,
let key = args["key"] as? String else {
return err("bucket and key required")
}
do {
try await AWSS3Service.shared.deleteObject(bucket: bucket, key: key, region: args["region"] as? String)
return ok("Deleted s3://\(bucket)/\(key)")
} catch {
return err(error.localizedDescription)
}
}
// aws_cloudfront_list
server.registerTool(
"aws_cloudfront_list",
description: "List CloudFront CDN distributions.",
inputSchema: ["type": "object", "properties": [:] as [String: Any]] as [String: Any],
annotations: ToolAnnotations(readOnlyHint: true)
) { _ in
do {
let distributions = try await AWSCloudFrontService.shared.listDistributions()
if distributions.isEmpty {
return ok("No CloudFront distributions found.")
}
var output = "CloudFront Distributions (\(distributions.count)):\n\n"
for d in distributions {
output += " \(d.id)\(d.domainName)\n"
output += " Status: \(d.status) | Enabled: \(d.enabled)\n"
if !d.aliases.isEmpty {
output += " Aliases: \(d.aliases.joined(separator: ", "))\n"
}
output += " Origins: \(d.origins.joined(separator: ", "))\n\n"
}
return ok(output)
} catch {
return err(error.localizedDescription)
}
}
// aws_cloudfront_create
server.registerTool(
"aws_cloudfront_create",
description: "Create a CloudFront distribution for an S3 website bucket.",
inputSchema: [
"type": "object",
"required": ["bucket"],
"properties": [
"bucket": ["type": "string", "description": "S3 bucket name (must have website hosting enabled)."],
"region": ["type": "string", "description": "S3 bucket region."],
"domains": ["type": "array", "items": ["type": "string"], "description": "Custom domain names (CNAMEs)."],
"certificate_arn": ["type": "string", "description": "ACM certificate ARN for HTTPS."],
] as [String: Any],
] as [String: Any]
) { args in
guard let bucket = args["bucket"] as? String else {
return err("bucket required")
}
let region = args["region"] as? String ?? AWSService.shared.defaultRegion
let domains = args["domains"] as? [String] ?? []
let certArn = args["certificate_arn"] as? String
do {
let dist = try await AWSCloudFrontService.shared.createDistributionForS3(
bucket: bucket, region: region,
aliases: domains, certificateArn: certArn
)
var output = "CloudFront Distribution Created:\n\n"
output += " ID: \(dist.id)\n"
output += " Domain: https://\(dist.domainName)\n"
output += " Status: \(dist.status)\n"
if !domains.isEmpty {
output += " CNAMEs: \(domains.joined(separator: ", "))\n"
}
output += "\n Note: Distribution may take 5-10 minutes to deploy globally."
return ok(output)
} catch {
return err(error.localizedDescription)
}
}
// aws_cloudfront_invalidate
server.registerTool(
"aws_cloudfront_invalidate",
description: "Invalidate CloudFront cache (clear cached content after updates).",
inputSchema: [
"type": "object",
"required": ["distribution_id"],
"properties": [
"distribution_id": ["type": "string", "description": "CloudFront distribution ID."],
"paths": ["type": "array", "items": ["type": "string"], "description": "Paths to invalidate (default: [/*])."],
] as [String: Any],
] as [String: Any]
) { args in
guard let distId = args["distribution_id"] as? String else {
return err("distribution_id required")
}
let paths = args["paths"] as? [String] ?? ["/*"]
do {
let invalidationId = try await AWSCloudFrontService.shared.createInvalidation(
distributionId: distId, paths: paths
)
return ok("Cache invalidation created (ID: \(invalidationId))\nPaths: \(paths.joined(separator: ", "))")
} catch {
return err(error.localizedDescription)
}
}
// aws_route53_zones
server.registerTool(
"aws_route53_zones",
description: "List or create Route 53 hosted zones.",
inputSchema: [
"type": "object",
"required": ["action"],
"properties": [
"action": ["type": "string", "description": "list or create."],
"domain": ["type": "string", "description": "Domain name (for create)."],
] as [String: Any],
] as [String: Any]
) { args in
let action = args["action"] as? String ?? "list"
let r53 = AWSRoute53Service.shared
do {
if action == "create" {
guard let domain = args["domain"] as? String, !domain.isEmpty else {
return err("domain required for create")
}
let zone = try await r53.createHostedZone(domain: domain)
var output = "Hosted Zone Created:\n\n"
output += " Zone ID: \(zone.id)\n"
output += " Domain: \(zone.domainName)\n"
if !zone.nameservers.isEmpty {
output += " Nameservers (set these at your registrar):\n"
for ns in zone.nameservers {
output += "\(ns)\n"
}
}
return ok(output)
} else {
let zones = try await r53.listHostedZones()
if zones.isEmpty {
return ok("No Route 53 hosted zones found.")
}
var output = "Route 53 Hosted Zones (\(zones.count)):\n\n"
for z in zones {
output += " \(z.id)\(z.domainName)\n"
output += " Records: \(z.recordCount) | Private: \(z.isPrivate)\n"
if let comment = z.comment { output += " Comment: \(comment)\n" }
output += "\n"
}
return ok(output)
}
} catch {
return err(error.localizedDescription)
}
}
// aws_route53_records
server.registerTool(
"aws_route53_records",
description: "List DNS records in a Route 53 hosted zone.",
inputSchema: [
"type": "object",
"required": ["zone_id"],
"properties": [
"zone_id": ["type": "string", "description": "Hosted zone ID."],
] as [String: Any],
] as [String: Any],
annotations: ToolAnnotations(readOnlyHint: true)
) { args in
guard let zoneId = args["zone_id"] as? String else {
return err("zone_id required")
}
do {
let records = try await AWSRoute53Service.shared.listRecordSets(zoneId: zoneId)
if records.isEmpty {
return ok("No records in zone \(zoneId)")
}
var output = "DNS Records (Zone: \(zoneId)):\n\n"
output += String(format: "%-8s %-35s %-10s %s\n", "Type", "Name", "TTL", "Value(s)")
output += String(repeating: "-", count: 90) + "\n"
for r in records {
let vals: String
if let alias = r.aliasTarget {
vals = "ALIAS → \(alias.dnsName)"
} else {
vals = r.values.joined(separator: ", ")
}
output += String(format: "%-8s %-35s %-10d %s\n",
r.type, String(r.name.prefix(35)), r.ttl, vals)
}
return ok(output)
} catch {
return err(error.localizedDescription)
}
}
// aws_route53_set
server.registerTool(
"aws_route53_set",
description: "Create or update a DNS record in Route 53.",
inputSchema: [
"type": "object",
"required": ["zone_id", "name", "type", "values"],
"properties": [
"zone_id": ["type": "string", "description": "Hosted zone ID."],
"name": ["type": "string", "description": "Record name (e.g., example.com or sub.example.com)."],
"type": ["type": "string", "description": "Record type: A, AAAA, CNAME, MX, TXT, NS."],
"values": ["type": "array", "items": ["type": "string"], "description": "Record values."],
"ttl": ["type": "integer", "description": "TTL in seconds (default: 300)."],
] as [String: Any],
] as [String: Any]
) { args in
guard let zoneId = args["zone_id"] as? String,
let name = args["name"] as? String,
let type = args["type"] as? String,
let values = args["values"] as? [String] else {
return err("zone_id, name, type, and values required")
}
let ttl = args["ttl"] as? Int ?? 300
do {
try await AWSRoute53Service.shared.upsertRecord(
zoneId: zoneId, name: name, type: type,
values: values, ttl: ttl
)
return ok("Set \(type) record: \(name)\(values.joined(separator: ", ")) (TTL: \(ttl))")
} catch {
return err(error.localizedDescription)
}
}
// aws_lambda_list
server.registerTool(
"aws_lambda_list",
description: "List Lambda functions or get function details.",
inputSchema: [
"type": "object",
"properties": [
"name": ["type": "string", "description": "Function name (omit to list all)."],
"region": ["type": "string", "description": "AWS region."],
] as [String: Any],
] as [String: Any],
annotations: ToolAnnotations(readOnlyHint: true)
) { args in
let lambda = AWSLambdaService.shared
let region = args["region"] as? String
do {
if let name = args["name"] as? String, !name.isEmpty {
let f = try await lambda.getFunction(name: name, region: region)
var output = "Lambda Function: \(f.name)\n\n"
output += " ARN: \(f.arn)\n"
output += " Runtime: \(f.runtime)\n"
output += " Handler: \(f.handler)\n"
output += " Memory: \(f.memorySize) MB\n"
output += " Timeout: \(f.timeout)s\n"
output += " Code Size: \(f.formattedCodeSize)\n"
if let desc = f.description, !desc.isEmpty { output += " Description: \(desc)\n" }
if let mod = f.lastModified { output += " Last Modified: \(mod)\n" }
return ok(output)
} else {
let functions = try await lambda.listFunctions(region: region)
if functions.isEmpty {
return ok("No Lambda functions found.")
}
var output = "Lambda Functions (\(functions.count)):\n\n"
for f in functions {
output += " \(f.name)\(f.runtime) | \(f.memorySize)MB | \(f.formattedCodeSize)\n"
}
return ok(output)
}
} catch {
return err(error.localizedDescription)
}
}
// aws_lambda_invoke
server.registerTool(
"aws_lambda_invoke",
description: "Invoke a Lambda function with optional JSON payload.",
inputSchema: [
"type": "object",
"required": ["name"],
"properties": [
"name": ["type": "string", "description": "Lambda function name."],
"payload": ["type": "object", "description": "JSON payload to send."],
"async": ["type": "boolean", "description": "Invoke asynchronously (default: false)."],
"region": ["type": "string", "description": "AWS region."],
] as [String: Any],
] as [String: Any]
) { args in
guard let name = args["name"] as? String else {
return err("Function name required")
}
let payload = args["payload"] as? [String: Any]
let isAsync = args["async"] as? Bool ?? false
let region = args["region"] as? String
do {
let result = try await AWSLambdaService.shared.invoke(
name: name, payload: payload,
async: isAsync, region: region
)
var output = "Lambda Invocation Result:\n\n"
output += " Status: \(result.statusCode)\n"
if isAsync {
output += " Mode: Async (fire-and-forget)\n"
} else {
output += " Response:\n\(result.payload)\n"
}
return ok(output)
} catch {
return err(error.localizedDescription)
}
}
// aws_ssl_certificate
server.registerTool(
"aws_ssl_certificate",
description: "Manage free SSL certificates via AWS Certificate Manager (ACM).",
inputSchema: [
"type": "object",
"required": ["action"],
"properties": [
"action": ["type": "string", "description": "list, request, or validate."],
"domain": ["type": "string", "description": "Domain for certificate (for request)."],
"arn": ["type": "string", "description": "Certificate ARN (for validate)."],
"alt_names": ["type": "array", "items": ["type": "string"], "description": "Subject alternative names."],
] as [String: Any],
] as [String: Any]
) { args in
let action = args["action"] as? String ?? "list"
let cf = AWSCloudFrontService.shared
do {
switch action.lowercased() {
case "list":
let certs = try await cf.listCertificates()
if certs.isEmpty {
return ok("No ACM certificates found.")
}
var output = "ACM Certificates (\(certs.count)):\n\n"
for c in certs {
output += " \(c.domainName)\(c.status)\n"
output += " ARN: \(c.arn)\n\n"
}
return ok(output)
case "request":
guard let domain = args["domain"] as? String, !domain.isEmpty else {
return err("domain required")
}
let altNames = args["alt_names"] as? [String] ?? []
let arn = try await cf.requestCertificate(domain: domain, subjectAlternativeNames: altNames)
var output = "SSL Certificate Requested:\n\n"
output += " Domain: \(domain)\n"
output += " ARN: \(arn)\n"
output += " Status: PENDING_VALIDATION\n\n"
output += " Next: Use action='validate' with arn to get DNS validation records."
return ok(output)
case "validate":
guard let arn = args["arn"] as? String, !arn.isEmpty else {
return err("arn required")
}
let records = try await cf.getCertificateValidation(arn: arn)
if records.isEmpty {
return ok("No validation records found (certificate may already be validated)")
}
var output = "Certificate Validation Records:\n\n"
output += "Add these CNAME records to your DNS:\n\n"
for r in records {
output += " Domain: \(r.domain)\n"
output += " Name: \(r.recordName)\n"
output += " Value: \(r.recordValue)\n"
output += " Type: \(r.recordType)\n\n"
}
return ok(output)
default:
return err("Unknown action '\(action)'. Available: list, request, validate")
}
} catch {
return err(error.localizedDescription)
}
}
}
// MARK: - Helpers
private static func ok(_ text: String) -> [[String: Any]] {
[["type": "text", "text": text]]
}
private static func err(_ message: String) -> [[String: Any]] {
[["type": "text", "text": "Error: \(message)"]]
}
}