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",
];