From ff94e26a0e256bbc4a4530fd334ae7cb19677f6e Mon Sep 17 00:00:00 2001 From: Harsh Mishra Date: Tue, 28 Apr 2026 00:10:29 +0530 Subject: [PATCH 1/5] first implementation --- README.md | 8 +- manifest.json | 20 ++ server.json | 28 ++ src/core/analytics.ts | 9 + .../localstack/aws-replicator.logic.test.ts | 100 ++++++ src/lib/localstack/localstack.client.ts | 72 +++++ src/tools/localstack-aws-replicator.ts | 287 ++++++++++++++++++ tests/mcp/direct.spec.mjs | 1 + 8 files changed, 524 insertions(+), 1 deletion(-) create mode 100644 src/lib/localstack/aws-replicator.logic.test.ts create mode 100644 src/tools/localstack-aws-replicator.ts diff --git a/README.md b/README.md index 111c368..e25ebc0 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
- 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,10 @@ 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_SESSION_TOKEN` (**required for AWS Replicator tool**) | Optional source AWS session token used by AWS Replicator for temporary credentials | None | +| `AWS_DEFAULT_REGION` (**required for AWS Replicator tool**) | Source AWS region used by AWS Replicator | None | ## Contributing @@ -119,7 +125,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..d58c35e 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,18 @@ "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-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..09f6cb1 100644 --- a/server.json +++ b/server.json @@ -24,6 +24,34 @@ "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" } ] } diff --git a/src/core/analytics.ts b/src/core/analytics.ts index 9a130c7..62119cb 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_type", + "has_resource_identifier", + "has_resource_arn", + "target_account_id", + "target_region_name", + ], "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..2bac11a --- /dev/null +++ b/src/lib/localstack/aws-replicator.logic.test.ts @@ -0,0 +1,100 @@ +import { + buildStartReplicationJobRequest, + formatReplicationJob, +} 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", + }, + }); + }); + }); + + 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"); + }); + }); +}); diff --git a/src/lib/localstack/localstack.client.ts b/src/lib/localstack/localstack.client.ts index 72e2baa..d09ffc2 100644 --- a/src/lib/localstack/localstack.client.ts +++ b/src/lib/localstack/localstack.client.ts @@ -4,6 +4,39 @@ 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; +} + // Chaos API Client export class ChaosApiClient { private async makeRequest( @@ -134,3 +167,42 @@ 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/jobs${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("", "POST", request); + } + + getJobStatus(jobId: string) { + return this.makeRequest(`/${encodeURIComponent(jobId)}`, "GET"); + } +} diff --git a/src/tools/localstack-aws-replicator.ts b/src/tools/localstack-aws-replicator.ts new file mode 100644 index 0000000..9a98fae --- /dev/null +++ b/src/tools/localstack-aws-replicator.ts @@ -0,0 +1,287 @@ +import { z } from "zod"; +import { type ToolMetadata, type InferSchema } from "xmcp"; +import { ResponseBuilder } from "../core/response-builder"; +import { runPreflights, requireAuthToken, requireLocalStackRunning } from "../core/preflight"; +import { withToolAnalytics } from "../core/analytics"; +import { + AwsReplicatorApiClient, + type AwsConfig, + type ReplicationJobResponse, + type StartReplicationJobRequest, +} from "../lib/localstack/localstack.client"; + +export const schema = { + action: z + .enum(["start", "status"]) + .describe("The AWS Replicator action to perform: start a job or check job status."), + 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."), + resource_identifier: z + .string() + .trim() + .optional() + .describe( + "Resource identifier to replicate. 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."), + 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. This is sent as the target AWS access key id for LocalStack account routing." + ), + 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_type: args.resource_type, + has_resource_identifier: Boolean(args.resource_identifier), + has_resource_arn: Boolean(args.resource_arn), + target_account_id: args.target_account_id, + target_region_name: args.target_region_name, + }, + async () => { + const preflightError = await runPreflights([requireAuthToken(), requireLocalStackRunning()]); + 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); + default: + return ResponseBuilder.error("Unknown action", `Unsupported action: ${args.action}`); + } + } + ); +} + +async function handleStart(client: AwsReplicatorApiClient, args: AwsReplicatorArgs) { + const validationError = validateStartArgs(args); + if (validationError) return validationError; + + const request = buildStartReplicationJobRequest(args); + 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)); +} + +function validateStartArgs(args: AwsReplicatorArgs) { + 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`." + ); + } + + const sourceAwsConfig = getSourceAwsConfigFromEnv(); + 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.` + ); + } + + return null; +} + +export function buildStartReplicationJobRequest( + args: AwsReplicatorArgs +): 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 sourceAwsConfig = getSourceAwsConfigFromEnv().config; + const targetAwsConfig = getTargetAwsConfig(args); + + 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(): { config: AwsConfig; missing: string[] } { + const config: AwsConfig = { + aws_access_key_id: firstNonEmpty( + process.env.AWS_REPLICATOR_SOURCE_AWS_ACCESS_KEY_ID, + process.env.AWS_ACCESS_KEY_ID + ), + aws_secret_access_key: firstNonEmpty( + process.env.AWS_REPLICATOR_SOURCE_AWS_SECRET_ACCESS_KEY, + process.env.AWS_SECRET_ACCESS_KEY + ), + region_name: firstNonEmpty( + process.env.AWS_REPLICATOR_SOURCE_REGION_NAME, + process.env.AWS_DEFAULT_REGION, + process.env.AWS_REGION + ), + }; + + const sessionToken = firstNonEmpty( + process.env.AWS_REPLICATOR_SOURCE_AWS_SESSION_TOKEN, + process.env.AWS_SESSION_TOKEN + ); + if (sessionToken) { + config.aws_session_token = sessionToken; + } + + const endpointUrl = firstNonEmpty( + process.env.AWS_REPLICATOR_SOURCE_ENDPOINT_URL, + process.env.AWS_ENDPOINT_URL + ); + if (endpointUrl) { + config.endpoint_url = endpointUrl; + } + + const missing: string[] = []; + if (!config.aws_access_key_id) missing.push("AWS_ACCESS_KEY_ID"); + if (!config.aws_secret_access_key) missing.push("AWS_SECRET_ACCESS_KEY"); + if (!config.region_name) missing.push("AWS_DEFAULT_REGION"); + + return { config, missing }; +} + +function getTargetAwsConfig(args: AwsReplicatorArgs): 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 + ); + const targetEndpointUrl = firstNonEmpty(process.env.AWS_REPLICATOR_TARGET_ENDPOINT_URL); + + if (!targetAccountId && !targetRegionName && !targetEndpointUrl) { + 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 || getSourceAwsConfigFromEnv().config.region_name, + }; + + const targetSessionToken = firstNonEmpty(process.env.AWS_REPLICATOR_TARGET_AWS_SESSION_TOKEN); + if (targetSessionToken) { + config.aws_session_token = targetSessionToken; + } + if (targetEndpointUrl) { + config.endpoint_url = targetEndpointUrl; + } + + return config; +} + +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; +} 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", ]; From ae17f2093d99a6c6d3e80c694861920a8f354f11 Mon Sep 17 00:00:00 2001 From: Harsh Mishra Date: Tue, 28 Apr 2026 00:17:09 +0530 Subject: [PATCH 2/5] add proper preflights --- src/lib/localstack/license-checker.ts | 1 + src/tools/localstack-aws-replicator.ts | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) 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/tools/localstack-aws-replicator.ts b/src/tools/localstack-aws-replicator.ts index 9a98fae..f4b69fe 100644 --- a/src/tools/localstack-aws-replicator.ts +++ b/src/tools/localstack-aws-replicator.ts @@ -1,8 +1,14 @@ import { z } from "zod"; import { type ToolMetadata, type InferSchema } from "xmcp"; import { ResponseBuilder } from "../core/response-builder"; -import { runPreflights, requireAuthToken, requireLocalStackRunning } from "../core/preflight"; +import { + runPreflights, + requireAuthToken, + requireLocalStackRunning, + requireProFeature, +} from "../core/preflight"; import { withToolAnalytics } from "../core/analytics"; +import { ProFeature } from "../lib/localstack/license-checker"; import { AwsReplicatorApiClient, type AwsConfig, @@ -79,7 +85,11 @@ export default async function localstackAwsReplicator(args: AwsReplicatorArgs) { target_region_name: args.target_region_name, }, async () => { - const preflightError = await runPreflights([requireAuthToken(), requireLocalStackRunning()]); + const preflightError = await runPreflights([ + requireAuthToken(), + requireLocalStackRunning(), + requireProFeature(ProFeature.REPLICATOR), + ]); if (preflightError) return preflightError; const client = new AwsReplicatorApiClient(); From 4792690c51e4a360fa7828b77fc3c901df0cc7f1 Mon Sep 17 00:00:00 2001 From: Harsh Mishra Date: Tue, 28 Apr 2026 00:26:35 +0530 Subject: [PATCH 3/5] add new actions (list, list-resources) to the tool --- README.md | 3 +- src/core/analytics.ts | 2 - .../localstack/aws-replicator.logic.test.ts | 40 +++++++++++ src/lib/localstack/localstack.client.ts | 22 +++++- src/tools/localstack-aws-replicator.ts | 70 +++++++++++++++++-- 5 files changed, 126 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e25ebc0..3c53997 100644 --- a/README.md +++ b/README.md @@ -36,7 +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
- Reads source AWS credentials from the MCP server environment and supports optional target account or region overrides | +| [`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 @@ -98,7 +98,6 @@ If you installed from source, change `command` and `args` to point to your local | `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_SESSION_TOKEN` (**required for AWS Replicator tool**) | Optional source AWS session token used by AWS Replicator for temporary credentials | None | | `AWS_DEFAULT_REGION` (**required for AWS Replicator tool**) | Source AWS region used by AWS Replicator | None | ## Contributing diff --git a/src/core/analytics.ts b/src/core/analytics.ts index 62119cb..c2e19cb 100644 --- a/src/core/analytics.ts +++ b/src/core/analytics.ts @@ -23,8 +23,6 @@ export const TOOL_ARG_ALLOWLIST: Record = { "resource_type", "has_resource_identifier", "has_resource_arn", - "target_account_id", - "target_region_name", ], "localstack-chaos-injector": ["action", "rules_count", "latency_ms"], "localstack-cloud-pods": ["action", "pod_name"], diff --git a/src/lib/localstack/aws-replicator.logic.test.ts b/src/lib/localstack/aws-replicator.logic.test.ts index 2bac11a..7194168 100644 --- a/src/lib/localstack/aws-replicator.logic.test.ts +++ b/src/lib/localstack/aws-replicator.logic.test.ts @@ -1,6 +1,8 @@ import { buildStartReplicationJobRequest, formatReplicationJob, + formatReplicationJobs, + formatSupportedResources, } from "../../tools/localstack-aws-replicator"; describe("localstack-aws-replicator", () => { @@ -97,4 +99,42 @@ describe("localstack-aws-replicator", () => { 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/localstack.client.ts b/src/lib/localstack/localstack.client.ts index d09ffc2..407f2da 100644 --- a/src/lib/localstack/localstack.client.ts +++ b/src/lib/localstack/localstack.client.ts @@ -37,6 +37,14 @@ export interface ReplicationJobResponse { [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( @@ -176,7 +184,7 @@ export class AwsReplicatorApiClient { body?: unknown ): Promise> { try { - const data = await httpClient.request(`/_localstack/replicator/jobs${endpoint}`, { + const data = await httpClient.request(`/_localstack/replicator${endpoint}`, { method, headers: { "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : undefined, @@ -199,10 +207,18 @@ export class AwsReplicatorApiClient { } startJob(request: StartReplicationJobRequest) { - return this.makeRequest("", "POST", request); + return this.makeRequest("/jobs", "POST", request); + } + + listJobs() { + return this.makeRequest("/jobs", "GET"); } getJobStatus(jobId: string) { - return this.makeRequest(`/${encodeURIComponent(jobId)}`, "GET"); + 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 index f4b69fe..170159e 100644 --- a/src/tools/localstack-aws-replicator.ts +++ b/src/tools/localstack-aws-replicator.ts @@ -13,13 +13,16 @@ import { AwsReplicatorApiClient, type AwsConfig, type ReplicationJobResponse, + type ReplicationSupportedResource, type StartReplicationJobRequest, } from "../lib/localstack/localstack.client"; export const schema = { action: z - .enum(["start", "status"]) - .describe("The AWS Replicator action to perform: start a job or check job status."), + .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") @@ -81,8 +84,6 @@ export default async function localstackAwsReplicator(args: AwsReplicatorArgs) { resource_type: args.resource_type, has_resource_identifier: Boolean(args.resource_identifier), has_resource_arn: Boolean(args.resource_arn), - target_account_id: args.target_account_id, - target_region_name: args.target_region_name, }, async () => { const preflightError = await runPreflights([ @@ -99,6 +100,10 @@ export default async function localstackAwsReplicator(args: AwsReplicatorArgs) { 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}`); } @@ -135,6 +140,24 @@ async function handleStatus(client: AwsReplicatorApiClient, jobId?: string) { 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)); +} + function validateStartArgs(args: AwsReplicatorArgs) { const hasArn = Boolean(args.resource_arn?.trim()); const hasTypeAndIdentifier = Boolean(args.resource_type?.trim() && args.resource_identifier?.trim()); @@ -295,3 +318,42 @@ export function formatReplicationJob(title: string, job: ReplicationJobResponse) 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; +} From 348ac16f28b6db749f8a0b6b3185250b18c1233a Mon Sep 17 00:00:00 2001 From: Harsh Mishra Date: Tue, 28 Apr 2026 00:35:42 +0530 Subject: [PATCH 4/5] final fixes --- src/tools/localstack-aws-replicator.ts | 47 ++++++++++++++------------ 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/src/tools/localstack-aws-replicator.ts b/src/tools/localstack-aws-replicator.ts index 170159e..c75d8fc 100644 --- a/src/tools/localstack-aws-replicator.ts +++ b/src/tools/localstack-aws-replicator.ts @@ -39,7 +39,7 @@ export const schema = { .trim() .optional() .describe( - "Resource identifier to replicate. For BATCH SSM parameter replication, this must be a path prefix such as /dev/." + "Resource identifier for the resource to replicate, such as a VPC ID (vpc-...), SSM parameter name, IAM role name, or ECR repository name. For BATCH SSM parameter replication, this must be a path prefix such as /dev/." ), resource_arn: z .string() @@ -52,7 +52,7 @@ export const schema = { .trim() .optional() .describe( - "Optional LocalStack target AWS account id override. This is sent as the target AWS access key id for LocalStack account routing." + "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() @@ -112,10 +112,11 @@ export default async function localstackAwsReplicator(args: AwsReplicatorArgs) { } async function handleStart(client: AwsReplicatorApiClient, args: AwsReplicatorArgs) { - const validationError = validateStartArgs(args); + const sourceAwsConfig = getSourceAwsConfigFromEnv(); + const validationError = validateStartArgs(args, sourceAwsConfig); if (validationError) return validationError; - const request = buildStartReplicationJobRequest(args); + const request = buildStartReplicationJobRequest(args, sourceAwsConfig.config); const result = await client.startJob(request); if (!result.success) { return ResponseBuilder.error("AWS Replicator Error", result.message); @@ -158,7 +159,18 @@ async function handleListSupportedResources(client: AwsReplicatorApiClient) { return ResponseBuilder.markdown(formatSupportedResources(result.data)); } -function validateStartArgs(args: AwsReplicatorArgs) { +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()); @@ -176,21 +188,12 @@ function validateStartArgs(args: AwsReplicatorArgs) { ); } - const sourceAwsConfig = getSourceAwsConfigFromEnv(); - 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.` - ); - } - return null; } export function buildStartReplicationJobRequest( - args: AwsReplicatorArgs + args: AwsReplicatorArgs, + sourceAwsConfig: AwsConfig = getSourceAwsConfigFromEnv().config ): StartReplicationJobRequest { const replicationJobConfig = args.resource_arn?.trim() @@ -200,8 +203,7 @@ export function buildStartReplicationJobRequest( resource_identifier: args.resource_identifier!.trim(), }; - const sourceAwsConfig = getSourceAwsConfigFromEnv().config; - const targetAwsConfig = getTargetAwsConfig(args); + const targetAwsConfig = getTargetAwsConfig(args, sourceAwsConfig.region_name); return { replication_type: args.replication_type, @@ -215,7 +217,7 @@ function firstNonEmpty(...values: Array): string | undefined return values.find((value) => value?.trim())?.trim(); } -function getSourceAwsConfigFromEnv(): { config: AwsConfig; missing: string[] } { +function getSourceAwsConfigFromEnv(): SourceAwsConfigResult { const config: AwsConfig = { aws_access_key_id: firstNonEmpty( process.env.AWS_REPLICATOR_SOURCE_AWS_ACCESS_KEY_ID, @@ -256,7 +258,10 @@ function getSourceAwsConfigFromEnv(): { config: AwsConfig; missing: string[] } { return { config, missing }; } -function getTargetAwsConfig(args: AwsReplicatorArgs): AwsConfig | undefined { +function getTargetAwsConfig( + args: AwsReplicatorArgs, + sourceRegionName?: string +): AwsConfig | undefined { const targetAccountId = firstNonEmpty( args.target_account_id, process.env.AWS_REPLICATOR_TARGET_ACCOUNT_ID, @@ -277,7 +282,7 @@ function getTargetAwsConfig(args: AwsReplicatorArgs): AwsConfig | undefined { aws_secret_access_key: firstNonEmpty( process.env.AWS_REPLICATOR_TARGET_AWS_SECRET_ACCESS_KEY ) || "test", - region_name: targetRegionName || getSourceAwsConfigFromEnv().config.region_name, + region_name: targetRegionName || sourceRegionName, }; const targetSessionToken = firstNonEmpty(process.env.AWS_REPLICATOR_TARGET_AWS_SESSION_TOKEN); From 08d2039f2c8808792f3cda0e38efd013cb16cfce Mon Sep 17 00:00:00 2001 From: Harsh Mishra Date: Wed, 29 Apr 2026 20:44:51 +0530 Subject: [PATCH 5/5] resolve @cloutierMat comments --- README.md | 2 + manifest.json | 6 ++ server.json | 35 +++++++ src/core/analytics.ts | 2 + .../localstack/aws-replicator.logic.test.ts | 38 +++++++ src/tools/localstack-aws-replicator.ts | 98 ++++++++++++------- 6 files changed, 146 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 3c53997..ea7d7ef 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,8 @@ If you installed from source, change `command` and `args` to point to your local | `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 Built on the [XMCP](https://github.com/basementstudio/xmcp) framework, you can add new tools by adding a new file to the `src/tools` directory and documenting it in the `manifest.json` file. diff --git a/manifest.json b/manifest.json index d58c35e..cc2e598 100644 --- a/manifest.json +++ b/manifest.json @@ -141,6 +141,12 @@ "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", diff --git a/server.json b/server.json index 09f6cb1..ac33b9c 100644 --- a/server.json +++ b/server.json @@ -52,6 +52,41 @@ "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 c2e19cb..6db7a29 100644 --- a/src/core/analytics.ts +++ b/src/core/analytics.ts @@ -20,7 +20,9 @@ export const TOOL_ARG_ALLOWLIST: Record = { "localstack-aws-replicator": [ "action", "replication_type", + "resource_target_kind", "resource_type", + "resource_arn_service", "has_resource_identifier", "has_resource_arn", ], diff --git a/src/lib/localstack/aws-replicator.logic.test.ts b/src/lib/localstack/aws-replicator.logic.test.ts index 7194168..cabe56e 100644 --- a/src/lib/localstack/aws-replicator.logic.test.ts +++ b/src/lib/localstack/aws-replicator.logic.test.ts @@ -75,6 +75,44 @@ describe("localstack-aws-replicator", () => { }, }); }); + + 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", () => { diff --git a/src/tools/localstack-aws-replicator.ts b/src/tools/localstack-aws-replicator.ts index c75d8fc..ee18d7a 100644 --- a/src/tools/localstack-aws-replicator.ts +++ b/src/tools/localstack-aws-replicator.ts @@ -33,19 +33,23 @@ export const schema = { .string() .trim() .optional() - .describe("CloudFormation resource type to replicate, e.g. AWS::EC2::VPC or AWS::SSM::Parameter."), + .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( - "Resource identifier for the resource to replicate, such as a VPC ID (vpc-...), SSM parameter name, IAM role name, or ECR repository name. For BATCH SSM parameter replication, this must be a path prefix such as /dev/." + "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."), + .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() @@ -81,7 +85,9 @@ export default async function localstackAwsReplicator(args: AwsReplicatorArgs) { { 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), }, @@ -218,42 +224,62 @@ function firstNonEmpty(...values: Array): string | undefined } function getSourceAwsConfigFromEnv(): SourceAwsConfigResult { - const config: AwsConfig = { - aws_access_key_id: firstNonEmpty( + const hasReplicatorSourceConfig = Boolean( + firstNonEmpty( process.env.AWS_REPLICATOR_SOURCE_AWS_ACCESS_KEY_ID, - process.env.AWS_ACCESS_KEY_ID - ), - aws_secret_access_key: firstNonEmpty( process.env.AWS_REPLICATOR_SOURCE_AWS_SECRET_ACCESS_KEY, - process.env.AWS_SECRET_ACCESS_KEY - ), - region_name: firstNonEmpty( + process.env.AWS_REPLICATOR_SOURCE_AWS_SESSION_TOKEN, process.env.AWS_REPLICATOR_SOURCE_REGION_NAME, - process.env.AWS_DEFAULT_REGION, - process.env.AWS_REGION - ), + 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 = firstNonEmpty( - process.env.AWS_REPLICATOR_SOURCE_AWS_SESSION_TOKEN, - process.env.AWS_SESSION_TOKEN - ); + 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 = firstNonEmpty( - process.env.AWS_REPLICATOR_SOURCE_ENDPOINT_URL, - process.env.AWS_ENDPOINT_URL - ); + 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("AWS_ACCESS_KEY_ID"); - if (!config.aws_secret_access_key) missing.push("AWS_SECRET_ACCESS_KEY"); - if (!config.region_name) missing.push("AWS_DEFAULT_REGION"); + 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 }; } @@ -271,9 +297,8 @@ function getTargetAwsConfig( args.target_region_name, process.env.AWS_REPLICATOR_TARGET_REGION_NAME ); - const targetEndpointUrl = firstNonEmpty(process.env.AWS_REPLICATOR_TARGET_ENDPOINT_URL); - if (!targetAccountId && !targetRegionName && !targetEndpointUrl) { + if (!targetAccountId && !targetRegionName) { return undefined; } @@ -285,17 +310,20 @@ function getTargetAwsConfig( region_name: targetRegionName || sourceRegionName, }; - const targetSessionToken = firstNonEmpty(process.env.AWS_REPLICATOR_TARGET_AWS_SESSION_TOKEN); - if (targetSessionToken) { - config.aws_session_token = targetSessionToken; - } - if (targetEndpointUrl) { - config.endpoint_url = targetEndpointUrl; - } - 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";