diff --git a/README.md b/README.md index 111c368..ea7d7ef 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ This server eliminates custom scripts and manual LocalStack management with dire - Manage LocalStack state snapshots via [Cloud Pods](https://docs.localstack.cloud/aws/capabilities/state-management/cloud-pods/) for development workflows. (requires active license) - Install, remove, list, and discover [LocalStack Extensions](https://docs.localstack.cloud/aws/capabilities/extensions/) from the marketplace. (requires active license) - Launch and manage [Ephemeral Instances](https://docs.localstack.cloud/aws/capabilities/cloud-sandbox/ephemeral-instances/) for remote LocalStack testing workflows. +- Replicate external AWS resources into LocalStack with [AWS Replicator](https://docs.localstack.cloud/aws/tooling/aws-replicator/) so IaC stacks can resolve shared dependencies locally. - Connect AI assistants and dev tools for automated cloud testing workflows. ## Tools Reference @@ -35,6 +36,7 @@ This server provides your AI with dedicated tools for managing your LocalStack e | [`localstack-extensions`](./src/tools/localstack-extensions.ts) | Installs, uninstalls, lists, and discovers LocalStack Extensions | - Manage installed extensions via CLI actions (`list`, `install`, `uninstall`)
- Browse the LocalStack Extensions marketplace (`available`)
- Requires a valid LocalStack Auth Token support | | [`localstack-ephemeral-instances`](./src/tools/localstack-ephemeral-instances.ts) | Manages cloud-hosted LocalStack Ephemeral Instances | - Create temporary cloud-hosted LocalStack instances and get an endpoint URL
- List available ephemeral instances, fetch logs, and delete instances
- Supports lifetime, extension preload, Cloud Pod preload, and custom env vars on create
- Requires a valid LocalStack Auth Token and LocalStack CLI | | [`localstack-aws-client`](./src/tools/localstack-aws-client.ts) | Runs AWS CLI commands inside the LocalStack for AWS container | - Executes commands via `awslocal` inside the running container
- Sanitizes commands to block shell chaining
- Auto-detects LocalStack coverage errors and links to docs | +| [`localstack-aws-replicator`](./src/tools/localstack-aws-replicator.ts) | Replicates external AWS resources into a running LocalStack instance | - Start single-resource replication jobs with a resource type and identifier or ARN
- Start batch replication jobs, such as SSM parameters under a path prefix
- Poll job status by job ID and list existing jobs
- List resource types supported by the running Replicator extension
- Reads source AWS credentials from the MCP server environment and supports optional target account or region overrides | | [`localstack-docs`](./src/tools/localstack-docs.ts) | Searches LocalStack documentation through CrawlChat | - Queries LocalStack docs through a public CrawlChat collection
- Returns focused snippets with source links only
- Helps answer coverage, configuration, and setup questions without requiring LocalStack runtime | ## Installation @@ -94,6 +96,11 @@ If you installed from source, change `command` and `args` to point to your local | `LOCALSTACK_AUTH_TOKEN` (**required**) | The LocalStack Auth Token to use for the MCP server | None | | `MAIN_CONTAINER_NAME` | The name of the LocalStack container to use for the MCP server | `localstack-main` | | `MCP_ANALYTICS_DISABLED` | Disable MCP analytics when set to `1` | `0` | +| `AWS_ACCESS_KEY_ID` (**required for AWS Replicator tool**) | Source AWS access key used by AWS Replicator to read external AWS resources | None | +| `AWS_SECRET_ACCESS_KEY` (**required for AWS Replicator tool**) | Source AWS secret access key used by AWS Replicator to read external AWS resources | None | +| `AWS_DEFAULT_REGION` (**required for AWS Replicator tool**) | Source AWS region used by AWS Replicator | None | + +For AWS Replicator-specific source credentials, you can use the `AWS_REPLICATOR_SOURCE_` prefixed variants instead of the unprefixed variants. Do not mix the prefixed and unprefixed source credential groups; when any `AWS_REPLICATOR_SOURCE_` variable is set, the Replicator tool reads the source configuration only from that group. ## Contributing @@ -119,7 +126,7 @@ This repository includes [MCP Server Tester](https://github.com/gleanwork/mcp-se export GOOGLE_GENERATIVE_AI_API_KEY="" export LOCALSTACK_AUTH_TOKEN="" yarn test:mcp:evals -``` + ``` - Open the latest MCP Server Tester HTML report: ```bash npx mcp-server-tester open diff --git a/manifest.json b/manifest.json index 66de18a..cc2e598 100644 --- a/manifest.json +++ b/manifest.json @@ -59,6 +59,14 @@ "name": "localstack-ephemeral-instances", "description": "Manage cloud-hosted LocalStack Ephemeral Instances by creating, listing, viewing logs, and deleting instances" }, + { + "name": "localstack-aws-client", + "description": "Runs AWS CLI commands inside the running LocalStack container" + }, + { + "name": "localstack-aws-replicator", + "description": "Replicates external AWS resources into a running LocalStack instance using the AWS Replicator HTTP API" + }, { "name": "localstack-docs", "description": "Search the LocalStack documentation to find guides, API references, and configuration details" @@ -127,6 +135,24 @@ "arguments": ["description"], "text": "Please query the LocalStack container for ${arguments.description}." }, + { + "name": "aws-replicator-start", + "description": "Start an AWS Replicator job", + "arguments": ["resource_type", "resource_identifier"], + "text": "Start an AWS Replicator job for ${arguments.resource_type} ${arguments.resource_identifier} into LocalStack using the AWS credentials configured in the MCP server environment." + }, + { + "name": "aws-replicator-start-by-arn", + "description": "Start an AWS Replicator job from a resource ARN", + "arguments": ["resource_arn"], + "text": "Start an AWS Replicator job for the resource ARN ${arguments.resource_arn} into LocalStack using the AWS credentials configured in the MCP server environment." + }, + { + "name": "aws-replicator-status", + "description": "Check AWS Replicator job status", + "arguments": ["job_id"], + "text": "Check the AWS Replicator job status for ${arguments.job_id}." + }, { "name": "cloud-pods", "description": "Manage LocalStack state snapshots (Cloud Pods) for saving, loading, deleting, and resetting", diff --git a/server.json b/server.json index 37a8fd0..ac33b9c 100644 --- a/server.json +++ b/server.json @@ -24,6 +24,69 @@ "format": "string", "is_secret": true, "name": "LOCALSTACK_AUTH_TOKEN" + }, + { + "description": "Source AWS access key used by AWS Replicator to read external AWS resources", + "is_required": false, + "format": "string", + "is_secret": true, + "name": "AWS_ACCESS_KEY_ID" + }, + { + "description": "Source AWS secret access key used by AWS Replicator to read external AWS resources", + "is_required": false, + "format": "string", + "is_secret": true, + "name": "AWS_SECRET_ACCESS_KEY" + }, + { + "description": "Optional source AWS session token used by AWS Replicator for temporary credentials", + "is_required": false, + "format": "string", + "is_secret": true, + "name": "AWS_SESSION_TOKEN" + }, + { + "description": "Source AWS region used by AWS Replicator", + "is_required": false, + "format": "string", + "is_secret": false, + "name": "AWS_DEFAULT_REGION" + }, + { + "description": "Optional Replicator-specific source AWS access key. Use this instead of AWS_ACCESS_KEY_ID when the Replicator source account should be isolated from generic AWS config.", + "is_required": false, + "format": "string", + "is_secret": true, + "name": "AWS_REPLICATOR_SOURCE_AWS_ACCESS_KEY_ID" + }, + { + "description": "Optional Replicator-specific source AWS secret access key. Use with AWS_REPLICATOR_SOURCE_AWS_ACCESS_KEY_ID.", + "is_required": false, + "format": "string", + "is_secret": true, + "name": "AWS_REPLICATOR_SOURCE_AWS_SECRET_ACCESS_KEY" + }, + { + "description": "Optional Replicator-specific source AWS session token for temporary credentials.", + "is_required": false, + "format": "string", + "is_secret": true, + "name": "AWS_REPLICATOR_SOURCE_AWS_SESSION_TOKEN" + }, + { + "description": "Optional Replicator-specific source AWS region. Use with the AWS_REPLICATOR_SOURCE_* credential group.", + "is_required": false, + "format": "string", + "is_secret": false, + "name": "AWS_REPLICATOR_SOURCE_REGION_NAME" + }, + { + "description": "Optional Replicator-specific source AWS endpoint URL for advanced source-account scenarios.", + "is_required": false, + "format": "string", + "is_secret": false, + "name": "AWS_REPLICATOR_SOURCE_ENDPOINT_URL" } ] } diff --git a/src/core/analytics.ts b/src/core/analytics.ts index 9a130c7..6db7a29 100644 --- a/src/core/analytics.ts +++ b/src/core/analytics.ts @@ -17,6 +17,15 @@ const SHUTDOWN_TIMEOUT_MS = 1000; export const TOOL_ARG_ALLOWLIST: Record = { "localstack-aws-client": ["command"], + "localstack-aws-replicator": [ + "action", + "replication_type", + "resource_target_kind", + "resource_type", + "resource_arn_service", + "has_resource_identifier", + "has_resource_arn", + ], "localstack-chaos-injector": ["action", "rules_count", "latency_ms"], "localstack-cloud-pods": ["action", "pod_name"], "localstack-deployer": [ diff --git a/src/lib/localstack/aws-replicator.logic.test.ts b/src/lib/localstack/aws-replicator.logic.test.ts new file mode 100644 index 0000000..cabe56e --- /dev/null +++ b/src/lib/localstack/aws-replicator.logic.test.ts @@ -0,0 +1,178 @@ +import { + buildStartReplicationJobRequest, + formatReplicationJob, + formatReplicationJobs, + formatSupportedResources, +} from "../../tools/localstack-aws-replicator"; + +describe("localstack-aws-replicator", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { + ...originalEnv, + AWS_ACCESS_KEY_ID: "AKIA...", + AWS_SECRET_ACCESS_KEY: "secret", + AWS_DEFAULT_REGION: "us-east-1", + }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe("buildStartReplicationJobRequest", () => { + it("builds a single-resource request from type and identifier using env credentials", () => { + const request = buildStartReplicationJobRequest({ + action: "start", + replication_type: "SINGLE_RESOURCE", + resource_type: "AWS::EC2::VPC", + resource_identifier: "vpc-123", + target_account_id: "111111111111", + target_region_name: "eu-central-1", + } as any); + + expect(request).toEqual({ + replication_type: "SINGLE_RESOURCE", + replication_job_config: { + resource_type: "AWS::EC2::VPC", + resource_identifier: "vpc-123", + }, + source_aws_config: { + aws_access_key_id: "AKIA...", + aws_secret_access_key: "secret", + region_name: "us-east-1", + }, + target_aws_config: { + aws_access_key_id: "111111111111", + aws_secret_access_key: "test", + region_name: "eu-central-1", + }, + }); + }); + + it("builds a resource ARN request without requiring resource type using env credentials", () => { + process.env.AWS_SESSION_TOKEN = "token"; + process.env.AWS_ENDPOINT_URL = "https://example.com"; + + const request = buildStartReplicationJobRequest({ + action: "start", + replication_type: "SINGLE_RESOURCE", + resource_arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:my-secret", + } as any); + + expect(request).toEqual({ + replication_type: "SINGLE_RESOURCE", + replication_job_config: { + resource_arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:my-secret", + }, + source_aws_config: { + aws_access_key_id: "AKIA...", + aws_secret_access_key: "secret", + aws_session_token: "token", + region_name: "us-east-1", + endpoint_url: "https://example.com", + }, + }); + }); + + it("does not mix prefixed source credentials with generic AWS session values", () => { + process.env.AWS_REPLICATOR_SOURCE_AWS_ACCESS_KEY_ID = "replicator-key"; + process.env.AWS_REPLICATOR_SOURCE_AWS_SECRET_ACCESS_KEY = "replicator-secret"; + process.env.AWS_REPLICATOR_SOURCE_REGION_NAME = "eu-west-1"; + process.env.AWS_SESSION_TOKEN = "generic-token"; + + const request = buildStartReplicationJobRequest({ + action: "start", + replication_type: "SINGLE_RESOURCE", + resource_type: "AWS::SSM::Parameter", + resource_identifier: "my-param", + } as any); + + expect(request.source_aws_config).toEqual({ + aws_access_key_id: "replicator-key", + aws_secret_access_key: "replicator-secret", + region_name: "eu-west-1", + }); + }); + + it("does not allow a target endpoint URL override", () => { + process.env.AWS_REPLICATOR_TARGET_ENDPOINT_URL = "https://not-localstack.example.com"; + + const request = buildStartReplicationJobRequest({ + action: "start", + replication_type: "SINGLE_RESOURCE", + resource_type: "AWS::EC2::VPC", + resource_identifier: "vpc-123", + target_region_name: "eu-central-1", + } as any); + + expect(request.target_aws_config).toEqual({ + aws_access_key_id: "test", + aws_secret_access_key: "test", + region_name: "eu-central-1", + }); + }); + }); + + describe("formatReplicationJob", () => { + it("includes batch result details", () => { + const formatted = formatReplicationJob("AWS Replicator Job Status", { + job_id: "job-123", + state: "SUCCEEDED", + type: "BATCH", + replication_config: { + resource_type: "AWS::SSM::Parameter", + identifier: "/dev/", + }, + result: { + resources_succeeded: 2, + resources_failed: 0, + }, + }); + + expect(formatted).toContain("AWS Replicator Job Status"); + expect(formatted).toContain("`job-123`"); + expect(formatted).toContain("resources_succeeded"); + expect(formatted).toContain("AWS::SSM::Parameter"); + }); + }); + + describe("formatReplicationJobs", () => { + it("summarizes listed jobs and includes the raw response", () => { + const formatted = formatReplicationJobs([ + { + job_id: "job-123", + state: "SUCCEEDED", + type: "SINGLE_RESOURCE", + replication_config: { + resource_type: "AWS::EC2::VPC", + resource_identifier: "vpc-123", + }, + }, + ]); + + expect(formatted).toContain("AWS Replicator Jobs"); + expect(formatted).toContain("job-123"); + expect(formatted).toContain("SUCCEEDED"); + expect(formatted).toContain("Raw Response"); + }); + }); + + describe("formatSupportedResources", () => { + it("summarizes supported resource types and identifiers", () => { + const formatted = formatSupportedResources([ + { + resource_type: "AWS::SSM::Parameter", + service: "ssm", + identifier: "Name", + }, + ]); + + expect(formatted).toContain("AWS Replicator Supported Resources"); + expect(formatted).toContain("AWS::SSM::Parameter"); + expect(formatted).toContain("identifier: `Name`"); + expect(formatted).toContain("Raw Response"); + }); + }); +}); diff --git a/src/lib/localstack/license-checker.ts b/src/lib/localstack/license-checker.ts index 2ce823f..fc69623 100644 --- a/src/lib/localstack/license-checker.ts +++ b/src/lib/localstack/license-checker.ts @@ -8,6 +8,7 @@ export enum ProFeature { CLOUD_PODS = "localstack.platform.plugin/pods", CHAOS_ENGINEERING = "localstack.platform.plugin/chaos", EXTENSIONS = "localstack.platform.plugin/extensions", + REPLICATOR = "localstack.platform.plugin/replicator", SNOWFLAKE = "localstack.aws.provider/snowflake:pro", } diff --git a/src/lib/localstack/localstack.client.ts b/src/lib/localstack/localstack.client.ts index 72e2baa..407f2da 100644 --- a/src/lib/localstack/localstack.client.ts +++ b/src/lib/localstack/localstack.client.ts @@ -4,6 +4,47 @@ export type ApiResult = | { success: true; data: T } | { success: false; message: string; statusCode?: number }; +export interface AwsConfig { + aws_access_key_id?: string; + aws_secret_access_key?: string; + aws_session_token?: string; + region_name?: string; + endpoint_url?: string; +} + +export interface ReplicationJobConfig { + resource_type?: string; + resource_identifier?: string; + resource_arn?: string; +} + +export interface StartReplicationJobRequest { + replication_type: "SINGLE_RESOURCE" | "BATCH"; + replication_job_config: ReplicationJobConfig; + source_aws_config: AwsConfig; + target_aws_config?: AwsConfig; +} + +export interface ReplicationJobResponse { + job_id: string; + state: string; + error_message?: string | null; + type?: string; + replication_type?: string; + replication_config?: Record; + replication_job_config?: Record; + result?: Record; + [key: string]: unknown; +} + +export interface ReplicationSupportedResource { + resource_type?: string; + service?: string; + identifier?: string; + policy_statements?: unknown[]; + [key: string]: unknown; +} + // Chaos API Client export class ChaosApiClient { private async makeRequest( @@ -134,3 +175,50 @@ export class CloudPodsApiClient { return this.makeRequest("/_localstack/state/reset", "POST", false, {}); } } + +// AWS Replicator API Client +export class AwsReplicatorApiClient { + private async makeRequest( + endpoint: string, + method: "GET" | "POST", + body?: unknown + ): Promise> { + try { + const data = await httpClient.request(`/_localstack/replicator${endpoint}`, { + method, + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + timeout: 300000, + }); + return { success: true, data }; + } catch (error) { + if (error instanceof HttpError) { + return { + success: false, + message: `❌ **Error:** The LocalStack AWS Replicator API returned an error (Status ${error.status}):\n\`\`\`\n${error.body}\n\`\`\``, + statusCode: error.status, + }; + } + return { + success: false, + message: `❌ **Error:** Failed to communicate with LocalStack AWS Replicator API: ${error instanceof Error ? error.message : "Unknown error"}`, + }; + } + } + + startJob(request: StartReplicationJobRequest) { + return this.makeRequest("/jobs", "POST", request); + } + + listJobs() { + return this.makeRequest("/jobs", "GET"); + } + + getJobStatus(jobId: string) { + return this.makeRequest(`/jobs/${encodeURIComponent(jobId)}`, "GET"); + } + + listSupportedResources() { + return this.makeRequest("/resources", "GET"); + } +} diff --git a/src/tools/localstack-aws-replicator.ts b/src/tools/localstack-aws-replicator.ts new file mode 100644 index 0000000..ee18d7a --- /dev/null +++ b/src/tools/localstack-aws-replicator.ts @@ -0,0 +1,392 @@ +import { z } from "zod"; +import { type ToolMetadata, type InferSchema } from "xmcp"; +import { ResponseBuilder } from "../core/response-builder"; +import { + runPreflights, + requireAuthToken, + requireLocalStackRunning, + requireProFeature, +} from "../core/preflight"; +import { withToolAnalytics } from "../core/analytics"; +import { ProFeature } from "../lib/localstack/license-checker"; +import { + AwsReplicatorApiClient, + type AwsConfig, + type ReplicationJobResponse, + type ReplicationSupportedResource, + type StartReplicationJobRequest, +} from "../lib/localstack/localstack.client"; + +export const schema = { + action: z + .enum(["start", "status", "list", "list-resources"]) + .describe( + "The AWS Replicator action to perform: start a job, check job status, list jobs, or list supported resource types." + ), + replication_type: z + .enum(["SINGLE_RESOURCE", "BATCH"]) + .default("SINGLE_RESOURCE") + .describe( + "Replication job type. Use SINGLE_RESOURCE for one resource, or BATCH for supported batch jobs such as SSM parameters under a path prefix." + ), + resource_type: z + .string() + .trim() + .optional() + .describe( + "CloudFormation resource type to replicate, e.g. AWS::EC2::VPC or AWS::SSM::Parameter. Use this with resource_identifier, or provide resource_arn instead." + ), + resource_identifier: z + .string() + .trim() + .optional() + .describe( + "CloudControl identifier for the resource to replicate, such as a VPC ID (vpc-...), SSM parameter name, IAM role name, or ECR repository name. Required when using resource_type and mutually exclusive with resource_arn. For BATCH SSM parameter replication, this must be a path prefix such as /dev/." + ), + resource_arn: z + .string() + .trim() + .optional() + .describe( + "Full ARN of the resource to replicate. Only supported for SINGLE_RESOURCE jobs and mutually exclusive with resource_type/resource_identifier." + ), + job_id: z.string().trim().optional().describe("Replication job id. Required for the status action."), + target_account_id: z + .string() + .trim() + .optional() + .describe( + "Optional LocalStack target AWS account id override. LocalStack defaults to account 000000000000, so this is only needed when replicating into a non-default account namespace." + ), + target_region_name: z + .string() + .trim() + .optional() + .describe("Optional LocalStack target AWS region override. Defaults to the source region."), +}; + +export const metadata: ToolMetadata = { + name: "localstack-aws-replicator", + description: + "Replicate external AWS resources into a running LocalStack instance using the AWS Replicator HTTP API.", + annotations: { + title: "LocalStack AWS Replicator", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }, +}; + +export type AwsReplicatorArgs = InferSchema; + +export default async function localstackAwsReplicator(args: AwsReplicatorArgs) { + return withToolAnalytics( + "localstack-aws-replicator", + { + action: args.action, + replication_type: args.replication_type, + resource_target_kind: getResourceTargetKind(args), + resource_type: args.resource_type, + resource_arn_service: getArnService(args.resource_arn), + has_resource_identifier: Boolean(args.resource_identifier), + has_resource_arn: Boolean(args.resource_arn), + }, + async () => { + const preflightError = await runPreflights([ + requireAuthToken(), + requireLocalStackRunning(), + requireProFeature(ProFeature.REPLICATOR), + ]); + if (preflightError) return preflightError; + + const client = new AwsReplicatorApiClient(); + + switch (args.action) { + case "start": + return await handleStart(client, args); + case "status": + return await handleStatus(client, args.job_id); + case "list": + return await handleListJobs(client); + case "list-resources": + return await handleListSupportedResources(client); + default: + return ResponseBuilder.error("Unknown action", `Unsupported action: ${args.action}`); + } + } + ); +} + +async function handleStart(client: AwsReplicatorApiClient, args: AwsReplicatorArgs) { + const sourceAwsConfig = getSourceAwsConfigFromEnv(); + const validationError = validateStartArgs(args, sourceAwsConfig); + if (validationError) return validationError; + + const request = buildStartReplicationJobRequest(args, sourceAwsConfig.config); + const result = await client.startJob(request); + if (!result.success) { + return ResponseBuilder.error("AWS Replicator Error", result.message); + } + + return ResponseBuilder.markdown(formatReplicationJob("AWS Replicator Job Started", result.data)); +} + +async function handleStatus(client: AwsReplicatorApiClient, jobId?: string) { + if (!jobId?.trim()) { + return ResponseBuilder.error( + "Missing Required Parameter", + "The `status` action requires the `job_id` parameter." + ); + } + + const result = await client.getJobStatus(jobId.trim()); + if (!result.success) { + return ResponseBuilder.error("AWS Replicator Error", result.message); + } + + return ResponseBuilder.markdown(formatReplicationJob("AWS Replicator Job Status", result.data)); +} + +async function handleListJobs(client: AwsReplicatorApiClient) { + const result = await client.listJobs(); + if (!result.success) { + return ResponseBuilder.error("AWS Replicator Error", result.message); + } + + return ResponseBuilder.markdown(formatReplicationJobs(result.data)); +} + +async function handleListSupportedResources(client: AwsReplicatorApiClient) { + const result = await client.listSupportedResources(); + if (!result.success) { + return ResponseBuilder.error("AWS Replicator Error", result.message); + } + + return ResponseBuilder.markdown(formatSupportedResources(result.data)); +} + +type SourceAwsConfigResult = { config: AwsConfig; missing: string[] }; + +function validateStartArgs(args: AwsReplicatorArgs, sourceAwsConfig: SourceAwsConfigResult) { + const missingSourceFields = sourceAwsConfig.missing.map((field) => `\`${field}\``); + + if (missingSourceFields.length > 0) { + return ResponseBuilder.error( + "Missing Source AWS Configuration", + `Configure ${missingSourceFields.join(", ")} in the MCP server environment. The Replicator uses these credentials to read the source AWS account.` + ); + } + + const hasArn = Boolean(args.resource_arn?.trim()); + const hasTypeAndIdentifier = Boolean(args.resource_type?.trim() && args.resource_identifier?.trim()); + + if (args.replication_type === "BATCH" && hasArn) { + return ResponseBuilder.error( + "Invalid Parameters", + "`resource_arn` is only supported for `SINGLE_RESOURCE` replication jobs." + ); + } + + if (hasArn === hasTypeAndIdentifier) { + return ResponseBuilder.error( + "Invalid Parameters", + "Provide exactly one resource target: either `resource_arn`, or both `resource_type` and `resource_identifier`." + ); + } + + return null; +} + +export function buildStartReplicationJobRequest( + args: AwsReplicatorArgs, + sourceAwsConfig: AwsConfig = getSourceAwsConfigFromEnv().config +): StartReplicationJobRequest { + const replicationJobConfig = + args.resource_arn?.trim() + ? { resource_arn: args.resource_arn.trim() } + : { + resource_type: args.resource_type!.trim(), + resource_identifier: args.resource_identifier!.trim(), + }; + + const targetAwsConfig = getTargetAwsConfig(args, sourceAwsConfig.region_name); + + return { + replication_type: args.replication_type, + replication_job_config: replicationJobConfig, + source_aws_config: sourceAwsConfig, + ...(targetAwsConfig ? { target_aws_config: targetAwsConfig } : {}), + }; +} + +function firstNonEmpty(...values: Array): string | undefined { + return values.find((value) => value?.trim())?.trim(); +} + +function getSourceAwsConfigFromEnv(): SourceAwsConfigResult { + const hasReplicatorSourceConfig = Boolean( + firstNonEmpty( + process.env.AWS_REPLICATOR_SOURCE_AWS_ACCESS_KEY_ID, + process.env.AWS_REPLICATOR_SOURCE_AWS_SECRET_ACCESS_KEY, + process.env.AWS_REPLICATOR_SOURCE_AWS_SESSION_TOKEN, + process.env.AWS_REPLICATOR_SOURCE_REGION_NAME, + process.env.AWS_REPLICATOR_SOURCE_ENDPOINT_URL + ) + ); + + const config: AwsConfig = { + aws_access_key_id: hasReplicatorSourceConfig + ? firstNonEmpty(process.env.AWS_REPLICATOR_SOURCE_AWS_ACCESS_KEY_ID) + : firstNonEmpty(process.env.AWS_ACCESS_KEY_ID), + aws_secret_access_key: hasReplicatorSourceConfig + ? firstNonEmpty(process.env.AWS_REPLICATOR_SOURCE_AWS_SECRET_ACCESS_KEY) + : firstNonEmpty(process.env.AWS_SECRET_ACCESS_KEY), + region_name: hasReplicatorSourceConfig + ? firstNonEmpty(process.env.AWS_REPLICATOR_SOURCE_REGION_NAME) + : firstNonEmpty(process.env.AWS_DEFAULT_REGION, process.env.AWS_REGION), + }; + + const sessionToken = hasReplicatorSourceConfig + ? firstNonEmpty(process.env.AWS_REPLICATOR_SOURCE_AWS_SESSION_TOKEN) + : firstNonEmpty(process.env.AWS_SESSION_TOKEN); + if (sessionToken) { + config.aws_session_token = sessionToken; + } + + const endpointUrl = hasReplicatorSourceConfig + ? firstNonEmpty(process.env.AWS_REPLICATOR_SOURCE_ENDPOINT_URL) + : firstNonEmpty(process.env.AWS_ENDPOINT_URL); + if (endpointUrl) { + config.endpoint_url = endpointUrl; + } + + const missing: string[] = []; + if (!config.aws_access_key_id) { + missing.push( + hasReplicatorSourceConfig + ? "AWS_REPLICATOR_SOURCE_AWS_ACCESS_KEY_ID" + : "AWS_ACCESS_KEY_ID" + ); + } + if (!config.aws_secret_access_key) { + missing.push( + hasReplicatorSourceConfig + ? "AWS_REPLICATOR_SOURCE_AWS_SECRET_ACCESS_KEY" + : "AWS_SECRET_ACCESS_KEY" + ); + } + if (!config.region_name) { + missing.push( + hasReplicatorSourceConfig ? "AWS_REPLICATOR_SOURCE_REGION_NAME" : "AWS_DEFAULT_REGION" + ); + } + + return { config, missing }; +} + +function getTargetAwsConfig( + args: AwsReplicatorArgs, + sourceRegionName?: string +): AwsConfig | undefined { + const targetAccountId = firstNonEmpty( + args.target_account_id, + process.env.AWS_REPLICATOR_TARGET_ACCOUNT_ID, + process.env.AWS_REPLICATOR_TARGET_AWS_ACCESS_KEY_ID + ); + const targetRegionName = firstNonEmpty( + args.target_region_name, + process.env.AWS_REPLICATOR_TARGET_REGION_NAME + ); + + if (!targetAccountId && !targetRegionName) { + return undefined; + } + + const config: AwsConfig = { + aws_access_key_id: targetAccountId || "test", + aws_secret_access_key: firstNonEmpty( + process.env.AWS_REPLICATOR_TARGET_AWS_SECRET_ACCESS_KEY + ) || "test", + region_name: targetRegionName || sourceRegionName, + }; + + return config; +} + +function getResourceTargetKind(args: AwsReplicatorArgs): "arn" | "type_identifier" | "unknown" { + if (args.resource_arn?.trim()) return "arn"; + if (args.resource_type?.trim() || args.resource_identifier?.trim()) return "type_identifier"; + return "unknown"; +} + +function getArnService(resourceArn?: string): string | undefined { + const parts = resourceArn?.trim().split(":"); + return parts && parts.length >= 3 && parts[0] === "arn" ? parts[2] : undefined; +} + +export function formatReplicationJob(title: string, job: ReplicationJobResponse): string { + const state = job.state || "UNKNOWN"; + const jobType = job.type || job.replication_type || "UNKNOWN"; + const config = job.replication_config || job.replication_job_config; + const result = job.result; + + let markdown = `## ${title}\n\n`; + markdown += `- **Job ID:** \`${job.job_id || "N/A"}\`\n`; + markdown += `- **State:** \`${state}\`\n`; + markdown += `- **Type:** \`${jobType}\`\n`; + + if (job.error_message) { + markdown += `- **Error:** ${job.error_message}\n`; + } + + if (config) { + markdown += `\n### Replication Config\n\n\`\`\`json\n${JSON.stringify(config, null, 2)}\n\`\`\`\n`; + } + + if (result) { + markdown += `\n### Result\n\n\`\`\`json\n${JSON.stringify(result, null, 2)}\n\`\`\`\n`; + } + + markdown += `\n### Raw Response\n\n\`\`\`json\n${JSON.stringify(job, null, 2)}\n\`\`\``; + + return markdown; +} + +export function formatReplicationJobs(jobs: ReplicationJobResponse[]): string { + if (!jobs.length) { + return "## AWS Replicator Jobs\n\nNo replication jobs found."; + } + + let markdown = `## AWS Replicator Jobs\n\nFound **${jobs.length}** replication job(s).\n\n`; + for (const job of jobs) { + markdown += `- **${job.job_id || "N/A"}** — \`${job.state || "UNKNOWN"}\` (${job.type || job.replication_type || "UNKNOWN"})`; + if (job.error_message) { + markdown += ` — ${job.error_message}`; + } + markdown += "\n"; + } + + markdown += `\n### Raw Response\n\n\`\`\`json\n${JSON.stringify(jobs, null, 2)}\n\`\`\``; + return markdown; +} + +export function formatSupportedResources(resources: ReplicationSupportedResource[]): string { + if (!resources.length) { + return "## AWS Replicator Supported Resources\n\nNo supported resources were returned by LocalStack."; + } + + let markdown = `## AWS Replicator Supported Resources\n\nFound **${resources.length}** supported resource type(s).\n\n`; + for (const resource of resources) { + markdown += `- **${resource.resource_type || "Unknown"}**`; + if (resource.service) { + markdown += ` (${resource.service})`; + } + if (resource.identifier) { + markdown += ` — identifier: \`${resource.identifier}\``; + } + markdown += "\n"; + } + + markdown += `\n### Raw Response\n\n\`\`\`json\n${JSON.stringify(resources, null, 2)}\n\`\`\``; + return markdown; +} diff --git a/tests/mcp/direct.spec.mjs b/tests/mcp/direct.spec.mjs index 7c1fa07..11cac41 100644 --- a/tests/mcp/direct.spec.mjs +++ b/tests/mcp/direct.spec.mjs @@ -11,6 +11,7 @@ const EXPECTED_TOOLS = [ "localstack-snowflake-client", "localstack-ephemeral-instances", "localstack-aws-client", + "localstack-aws-replicator", "localstack-docs", ];