diff --git a/README.md b/README.md index 75a3d04..10a64c5 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,14 @@ This server eliminates custom scripts and manual LocalStack management with dire - Start, stop, restart, and monitor LocalStack for AWS container status with built-in auth. - Deploy CDK, Terraform, and SAM projects with automatic configuration detection. - Search LocalStack documentation for guides, API references, and configuration details. -- Parse logs, catch errors, and auto-generate IAM policies from violations. (requires active license) -- Inject chaos faults and network effects into LocalStack to test system resilience. (requires active license) -- 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) +- Parse logs, catch errors, and auto-generate IAM policies from violations. +- Inject chaos faults and network effects into LocalStack to test system resilience. +- Manage LocalStack state snapshots via [Cloud Pods](https://docs.localstack.cloud/aws/capabilities/state-management/cloud-pods/) for development workflows. +- Export, import, inspect, and reset LocalStack state locally with [Export & Import State](https://docs.localstack.cloud/aws/capabilities/state-management/export-import-state/) file-based workflows. +- Install, remove, list, and discover [LocalStack Extensions](https://docs.localstack.cloud/aws/capabilities/extensions/) from the marketplace. - 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. -- Inspect LocalStack application flows with [App Inspector](https://docs.localstack.cloud/aws/capabilities/web-app/app-inspector/) traces, spans, events, payload metadata, and IAM policy evaluations. (requires active license) +- Inspect LocalStack application flows with [App Inspector](https://docs.localstack.cloud/aws/capabilities/web-app/app-inspector/) traces, spans, events, payload metadata, and IAM policy evaluations. - Connect AI assistants and dev tools for automated cloud testing workflows. ## Tools Reference @@ -26,20 +27,21 @@ This server provides your AI with dedicated tools for managing your LocalStack e > [!NOTE] > All tools in this MCP server require `LOCALSTACK_AUTH_TOKEN`. -| Tool Name | Description | Key Features | -| :-------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`localstack-management`](./src/tools/localstack-management.ts) | Manages LocalStack runtime operations for AWS and Snowflake stacks | - Execute start, stop, restart, and status checks
- Integrate LocalStack authentication tokens
- Inject custom environment variables
- Verify real-time status and perform health monitoring | -| [`localstack-deployer`](./src/tools/localstack-deployer.ts) | Handles infrastructure deployment to LocalStack for AWS environments | - Automatically run CDK, Terraform, and SAM tooling to deploy infrastructure locally
- Enable parameterized deployments with variable support
- Process and present deployment results
- Requires you to have [`cdklocal`](https://github.com/localstack/aws-cdk-local), [`tflocal`](https://github.com/localstack/terraform-local), or [`samlocal`](https://github.com/localstack/aws-sam-cli-local) installed in your system path | -| [`localstack-logs-analysis`](./src/tools/localstack-logs-analysis.ts) | Analyzes LocalStack for AWS logs for troubleshooting and insights | - Offer multiple analysis options including summaries, errors, requests, and raw data
- Filter by specific services and operations
- Generate API call metrics and failure breakdowns
- Group errors intelligently and identify patterns | -| [`localstack-iam-policy-analyzer`](./src/tools/localstack-iam-policy-analyzer.ts) | Handles IAM policy management and violation remediation | - Set IAM enforcement levels including `enforced`, `soft`, and `disabled` modes
- Search logs for permission-related violations
- Generate IAM policies automatically from detected access failures
- Requires a valid LocalStack Auth Token | -| [`localstack-chaos-injector`](./src/tools/localstack-chaos-injector.ts) | Injects and manages chaos experiment faults for system resilience testing | - Inject, add, remove, and clear service fault rules
- Configure network latency effects
- Comprehensive fault targeting by service, region, and operation
- Built-in workflow guidance for chaos experiments
- Requires a valid LocalStack Auth Token | -| [`localstack-cloud-pods`](./src/tools/localstack-cloud-pods.ts) | Manages LocalStack state snapshots for development workflows | - Save current state as Cloud Pods
- Load previously saved Cloud Pods instantly
- Delete Cloud Pods or reset to a clean state
- Requires a valid LocalStack Auth Token | -| [`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-app-inspector`](./src/tools/localstack-app-inspector.ts) | Inspects LocalStack application traces, spans, events, and IAM evaluations | - Enable or disable App Inspector for the running LocalStack instance
- List and inspect traces to understand AWS service-to-service flows
- Drill into spans, events, payload metadata, and IAM policy evaluation events
- Filter by service, region, operation, resource, ARN, status, and time range
- Requires a valid LocalStack Auth Token and the App Inspector feature in the connected LocalStack license | -| [`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 | +| Tool Name | Description | Key Features | +| :-------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`localstack-management`](./src/tools/localstack-management.ts) | Manages LocalStack runtime operations for AWS and Snowflake stacks | - Execute start, stop, restart, and status checks
- Integrate LocalStack authentication tokens
- Inject custom environment variables
- Verify real-time status and perform health monitoring | +| [`localstack-deployer`](./src/tools/localstack-deployer.ts) | Handles infrastructure deployment to LocalStack for AWS environments | - Automatically run CDK, Terraform, and SAM tooling to deploy infrastructure locally
- Enable parameterized deployments with variable support
- Process and present deployment results
- Requires you to have [`cdklocal`](https://github.com/localstack/aws-cdk-local), [`tflocal`](https://github.com/localstack/terraform-local), or [`samlocal`](https://github.com/localstack/aws-sam-cli-local) installed in your system path | +| [`localstack-logs-analysis`](./src/tools/localstack-logs-analysis.ts) | Analyzes LocalStack for AWS logs for troubleshooting and insights | - Offer multiple analysis options including summaries, errors, requests, and raw data
- Filter by specific services and operations
- Generate API call metrics and failure breakdowns
- Group errors intelligently and identify patterns | +| [`localstack-iam-policy-analyzer`](./src/tools/localstack-iam-policy-analyzer.ts) | Handles IAM policy management and violation remediation | - Set IAM enforcement levels including `enforced`, `soft`, and `disabled` modes
- Search logs for permission-related violations
- Generate IAM policies automatically from detected access failures
- Requires a valid LocalStack Auth Token | +| [`localstack-chaos-injector`](./src/tools/localstack-chaos-injector.ts) | Injects and manages chaos experiment faults for system resilience testing | - Inject, add, remove, and clear service fault rules
- Configure network latency effects
- Comprehensive fault targeting by service, region, and operation
- Built-in workflow guidance for chaos experiments
- Requires a valid LocalStack Auth Token | +| [`localstack-cloud-pods`](./src/tools/localstack-cloud-pods.ts) | Manages remote LocalStack Cloud Pods for development workflows | - Save current state as a Cloud Pod
- Load previously saved Cloud Pods instantly
- Delete Cloud Pods from remote cloud-backed storage
- Use this for managed remote state snapshots, not local export/import files
- Requires a valid LocalStack Auth Token | +| [`localstack-state-management`](./src/tools/localstack-state-management.ts) | Manages local file-based LocalStack state export/import workflows | - Export LocalStack state to a local file on disk through the LocalStack State REST API
- Import LocalStack state from a local file
- Inspect current LocalStack state as JSON metamodel data
- Reset all state or only selected services
- Supports service-level granularity for export, reset, and inspect
- Use this for local disk workflows; use Cloud Pods for remote cloud-backed snapshots
- Requires a valid LocalStack Auth Token | +| [`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-app-inspector`](./src/tools/localstack-app-inspector.ts) | Inspects LocalStack application traces, spans, events, and IAM evaluations | - Enable or disable App Inspector for the running LocalStack instance
- List and inspect traces to understand AWS service-to-service flows
- Drill into spans, events, payload metadata, and IAM policy evaluation events
- Filter by service, region, operation, resource, ARN, status, and time range
- Requires a valid LocalStack Auth Token and the App Inspector feature in the connected LocalStack license | +| [`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 diff --git a/manifest.json b/manifest.json index 192d2b5..948871b 100644 --- a/manifest.json +++ b/manifest.json @@ -45,7 +45,11 @@ }, { "name": "localstack-cloud-pods", - "description": "Manages LocalStack state snapshots (Cloud Pods) for saving, loading, deleting, and resetting" + "description": "Manages remote LocalStack Cloud Pods for saving, loading, and deleting cloud-backed state snapshots" + }, + { + "name": "localstack-state-management", + "description": "Exports, imports, resets, and inspects LocalStack state with local file-based workflows on disk" }, { "name": "localstack-extensions", @@ -176,9 +180,33 @@ }, { "name": "cloud-pods", - "description": "Manage LocalStack state snapshots (Cloud Pods) for saving, loading, deleting, and resetting", + "description": "Manage remote LocalStack Cloud Pods for saving, loading, and deleting cloud-backed snapshots", "arguments": ["action", "pod_name"], - "text": "Please ${arguments.action} Cloud Pod in the LocalStack container with the pod name ${arguments.pod_name}." + "text": "Please ${arguments.action} Cloud Pod in the LocalStack container with the pod name ${arguments.pod_name}. Use Cloud Pods for remote cloud-backed snapshots; use LocalStack state management for local export/import files on disk." + }, + { + "name": "localstack-state-export", + "description": "Export LocalStack state to a local file on disk", + "arguments": ["file_path", "services"], + "text": "Export my running LocalStack state to the local file ${arguments.file_path}. If services are provided (${arguments.services}), export only those services. Use this local file workflow instead of Cloud Pods because I want a disk file." + }, + { + "name": "localstack-state-import", + "description": "Import LocalStack state from a local file on disk", + "arguments": ["file_path"], + "text": "Import LocalStack state from the local file ${arguments.file_path}. This is for local file-based restore; use Cloud Pods instead when I want remote cloud-backed snapshots." + }, + { + "name": "localstack-state-inspect", + "description": "Inspect current LocalStack state locally", + "arguments": ["services"], + "text": "Inspect the current LocalStack state in JSON format. If services are provided (${arguments.services}), show only those services. Explain that this is local runtime state inspection and not a Cloud Pods remote snapshot operation." + }, + { + "name": "localstack-state-reset", + "description": "Reset LocalStack state locally", + "arguments": ["services"], + "text": "Reset LocalStack state. If services are provided (${arguments.services}), reset only those services; otherwise reset all service state. Warn me that this is destructive and separate from deleting Cloud Pods." }, { "name": "extensions-list", diff --git a/src/core/analytics.ts b/src/core/analytics.ts index 72698e4..f8cade4 100644 --- a/src/core/analytics.ts +++ b/src/core/analytics.ts @@ -40,6 +40,7 @@ export const TOOL_ARG_ALLOWLIST: Record = { ], "localstack-chaos-injector": ["action", "rules_count", "latency_ms"], "localstack-cloud-pods": ["action", "pod_name"], + "localstack-state-management": ["action", "has_file_path", "services_count"], "localstack-deployer": [ "action", "projectType", diff --git a/src/lib/localstack/license-checker.ts b/src/lib/localstack/license-checker.ts index c732b84..33b34ae 100644 --- a/src/lib/localstack/license-checker.ts +++ b/src/lib/localstack/license-checker.ts @@ -11,6 +11,7 @@ export enum ProFeature { EXTENSIONS = "localstack.platform.plugin/extensions", REPLICATOR = "localstack.platform.plugin/replicator", SNOWFLAKE = "localstack.aws.provider/snowflake:pro", + STATE_MANAGEMENT = "localstack.platform.plugin/snapshot", } export interface LicenseCheckResult { diff --git a/src/lib/localstack/localstack.client.ts b/src/lib/localstack/localstack.client.ts index 830d5ae..eb9e91a 100644 --- a/src/lib/localstack/localstack.client.ts +++ b/src/lib/localstack/localstack.client.ts @@ -1,3 +1,4 @@ +import { LOCALSTACK_BASE_URL } from "../../core/config"; import { httpClient, HttpError } from "../../core/http-client"; export type ApiResult = @@ -58,6 +59,13 @@ export interface AppInspectorSetStatusResponse { export type AppInspectorStatus = "enabled" | "disabled"; export type AppInspectorQuery = Record; +export interface StateExportResult { + content: Buffer; + services: string[]; + size: number; + contentLength?: number; +} + // Chaos API Client export class ChaosApiClient { private async makeRequest( @@ -184,8 +192,116 @@ export class CloudPodsApiClient { deletePod(podName: string) { return this.makeRequest(`/_localstack/pods/${encodeURIComponent(podName)}`, "DELETE", true, {}); } - resetState() { - return this.makeRequest("/_localstack/state/reset", "POST", false, {}); +} + +// Local file-based State Management API Client +export class StateManagementApiClient { + private async requestResponse( + endpoint: string, + options: RequestInit = {} + ): Promise> { + try { + const response = await fetch(`${LOCALSTACK_BASE_URL}${endpoint}`, options); + if (!response.ok) { + return { + success: false, + message: await response.text(), + statusCode: response.status, + }; + } + return { success: true, data: response }; + } catch (error) { + return { + success: false, + message: `Failed to communicate with LocalStack State Management API: ${error instanceof Error ? error.message : "Unknown error"}`, + }; + } + } + + private serviceQuery(services?: string[]) { + if (!services || services.length === 0) return ""; + return `?services=${encodeURIComponent(services.join(","))}`; + } + + async exportState(services?: string[]): Promise> { + const result = await this.requestResponse( + `/_localstack/pods/state${this.serviceQuery(services)}`, + { + method: "GET", + } + ); + if (!result.success) return result; + + const response = result.data; + const content = Buffer.from(await response.arrayBuffer()); + const exportedServices = (response.headers.get("x-localstack-pod-services") ?? "") + .split(",") + .map((service) => service.trim()) + .filter(Boolean); + const size = Number(response.headers.get("x-localstack-pod-size") ?? content.length); + const contentLength = Number(response.headers.get("content-length") ?? content.length); + + return { + success: true, + data: { + content, + services: exportedServices, + size: Number.isNaN(size) ? content.length : size, + contentLength: Number.isNaN(contentLength) ? undefined : contentLength, + }, + }; + } + + async importState(content: Buffer): Promise> { + const body = content.buffer.slice( + content.byteOffset, + content.byteOffset + content.byteLength + ) as ArrayBuffer; + const result = await this.requestResponse("/_localstack/pods", { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body, + }); + if (!result.success) return result; + return { success: true, data: await result.data.text() }; + } + + async resetState(services?: string[]): Promise> { + if (!services || services.length === 0) { + const result = await this.requestResponse("/_localstack/state/reset", { method: "POST" }); + return result.success ? { success: true, data: undefined } : result; + } + + for (const service of services) { + const result = await this.requestResponse( + `/_localstack/state/${encodeURIComponent(service)}/reset`, + { method: "POST" } + ); + if (!result.success) return result; + } + + return { success: true, data: undefined }; + } + + async inspectState(): Promise> { + try { + const data = await httpClient.request("/_localstack/pods/state/metamodel", { + method: "GET", + }); + return { success: true, data }; + } catch (error) { + if (error instanceof HttpError) { + return { + success: false, + message: error.body || error.message, + statusCode: error.status, + }; + } + return { + success: false, + message: `Failed to communicate with LocalStack State Management API: ${error instanceof Error ? error.message : "Unknown error"}`, + }; + } } } diff --git a/src/lib/localstack/state-management.logic.test.ts b/src/lib/localstack/state-management.logic.test.ts new file mode 100644 index 0000000..21aebe4 --- /dev/null +++ b/src/lib/localstack/state-management.logic.test.ts @@ -0,0 +1,128 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { + buildStateAnalyticsArgs, + filterInspectServices, + formatInspectResult, + normalizeServices, + validateStateManagementArgs, +} from "../../tools/localstack-state-management"; + +describe("localstack-state-management", () => { + describe("normalizeServices", () => { + it("accepts comma-delimited and array service inputs", () => { + expect(normalizeServices("s3, lambda, s3")).toEqual(["s3", "lambda"]); + expect(normalizeServices(["sqs", "sns", "sqs"])).toEqual(["sqs", "sns"]); + }); + }); + + describe("validateStateManagementArgs", () => { + it("validates export with file path and services", () => { + const filePath = path.join(os.tmpdir(), "ls-state-export-test.zip"); + const result = validateStateManagementArgs({ + action: "export", + file_path: filePath, + services: ["s3", "lambda"], + } as any); + + expect(result.error).toBeUndefined(); + expect(result.outputPath).toBe(filePath); + expect(result.serviceList).toEqual(["s3", "lambda"]); + }); + + it("requires an existing file for import and rejects service filters", () => { + const filePath = path.join(os.tmpdir(), "ls-state-import-test.zip"); + fs.writeFileSync(filePath, "state"); + + try { + const result = validateStateManagementArgs({ + action: "import", + file_path: filePath, + services: "s3", + } as any); + + expect(result.error?.content[0].text).toContain("Unsupported Service Filter"); + } finally { + fs.unlinkSync(filePath); + } + }); + + it("validates service-level reset", () => { + const result = validateStateManagementArgs({ + action: "reset", + services: ["s3", "sqs"], + } as any); + + expect(result.error).toBeUndefined(); + expect(result.serviceList).toEqual(["s3", "sqs"]); + }); + + it("validates inspect without requiring a file path", () => { + const result = validateStateManagementArgs({ + action: "inspect", + } as any); + + expect(result.error).toBeUndefined(); + expect(result.serviceList).toEqual([]); + }); + }); + + describe("buildStateAnalyticsArgs", () => { + it("does not include raw file paths or service names", () => { + const analyticsArgs = buildStateAnalyticsArgs({ + action: "export", + file_path: "/tmp/customer-state.zip", + services: ["s3", "lambda"], + } as any); + + expect(analyticsArgs).toEqual({ + action: "export", + has_file_path: true, + services_count: 2, + }); + expect(JSON.stringify(analyticsArgs)).not.toContain("/tmp/customer-state.zip"); + expect(JSON.stringify(analyticsArgs)).not.toContain("lambda"); + }); + }); + + describe("filterInspectServices", () => { + it("filters account-scoped inspect data to selected services", () => { + const filtered = filterInspectServices( + { + "000000000000": { + s3: { buckets: ["test"] }, + lambda: { functions: ["fn"] }, + sqs: { queues: ["q"] }, + }, + }, + ["s3", "sqs"] + ); + + expect(filtered).toEqual({ + "000000000000": { + s3: { buckets: ["test"] }, + sqs: { queues: ["q"] }, + }, + }); + }); + }); + + describe("formatInspectResult", () => { + it("returns filtered JSON markdown for selected services", () => { + const result = formatInspectResult( + { + "000000000000": { + s3: { buckets: ["test"] }, + lambda: { functions: ["fn"] }, + }, + }, + ["s3"] + ); + + expect(result.content[0].text).toContain("LocalStack State Inspect"); + expect(result.content[0].text).toContain('"s3"'); + expect(result.content[0].text).not.toContain('"lambda"'); + }); + }); +}); diff --git a/src/tools/localstack-cloud-pods.ts b/src/tools/localstack-cloud-pods.ts index 12e0be4..4206173 100644 --- a/src/tools/localstack-cloud-pods.ts +++ b/src/tools/localstack-cloud-pods.ts @@ -14,7 +14,11 @@ import { withToolAnalytics } from "../core/analytics"; // Define the schema for tool parameters export const schema = { - action: z.enum(["save", "load", "delete", "reset"]).describe("The Cloud Pods action to perform."), + action: z + .enum(["save", "load", "delete"]) + .describe( + "The Cloud Pods action to perform." + ), pod_name: z .string() @@ -34,7 +38,7 @@ export const schema = { // Define tool metadata export const metadata: ToolMetadata = { name: "localstack-cloud-pods", - description: "Manages LocalStack Cloud Pods with following actions: save, load, delete, reset", + description: "Manages remote LocalStack Cloud Pods with following actions: save, load, delete", annotations: { title: "LocalStack Cloud Pods", readOnlyHint: false, @@ -127,17 +131,6 @@ export default async function localstackCloudPods({ return ResponseBuilder.success(`Cloud Pod '**${pod_name}**' has been permanently deleted.`); } - case "reset": { - const result = await client.resetState(); - if (!result.success) { - return ResponseBuilder.error("Cloud Pods Error", result.message); - } - - return ResponseBuilder.markdown( - "⚠️ LocalStack state has been reset successfully. **All unsaved state has been permanently lost.**" - ); - } - default: return ResponseBuilder.error("Unknown action", `Unsupported action: ${action}`); } diff --git a/src/tools/localstack-state-management.ts b/src/tools/localstack-state-management.ts new file mode 100644 index 0000000..24554fd --- /dev/null +++ b/src/tools/localstack-state-management.ts @@ -0,0 +1,284 @@ +import fs from "fs"; +import path from "path"; +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 { + StateManagementApiClient, + type ApiResult, + type StateExportResult, +} from "../lib/localstack/localstack.client"; + +const DEFAULT_EXPORT_PATH = "ls-state-export"; + +export const schema = { + action: z + .enum(["export", "import", "reset", "inspect"]) + .describe( + "The local LocalStack state action to perform through the LocalStack State REST API. Use this tool for file-based state export/import workflows on disk. Use Cloud Pods instead when the user wants remote cloud-backed state snapshots." + ), + file_path: z + .string() + .trim() + .optional() + .describe( + "Local file path for state export or import. Required for import. For export, defaults to ls-state-export in the MCP server working directory if omitted." + ), + services: z + .union([z.array(z.string().trim().min(1)), z.string().trim().min(1)]) + .optional() + .describe( + "Optional AWS service names for service-level granularity, such as ['s3', 'lambda'] or 's3,lambda'. Supported for export, reset, and inspect. Import restores the services contained in the state file." + ), +}; + +export const metadata: ToolMetadata = { + name: "localstack-state-management", + description: + "Export, import, reset, and inspect LocalStack state using local file-based workflows on disk.", + annotations: { + title: "LocalStack State Management", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, +}; + +export type StateManagementArgs = InferSchema; + +interface ValidationResult { + error?: ReturnType; + serviceList?: string[]; + outputPath?: string; +} + +export default async function localstackStateManagement(args: StateManagementArgs) { + return withToolAnalytics( + "localstack-state-management", + buildStateAnalyticsArgs(args), + async () => { + const preflightError = await runPreflights([ + requireAuthToken(), + requireLocalStackRunning(), + requireProFeature(ProFeature.STATE_MANAGEMENT), + ]); + if (preflightError) return preflightError; + + const validation = validateStateManagementArgs(args); + if (validation.error) return validation.error; + + const client = new StateManagementApiClient(); + + switch (args.action) { + case "export": + return await handleExport(client, validation); + case "import": + return await handleImport(client, validation); + case "reset": + return await handleReset(client, validation); + case "inspect": + return await handleInspect(client, validation); + default: + return ResponseBuilder.error("Unknown Action", `Unsupported action: ${args.action}`); + } + } + ); +} + +export function buildStateAnalyticsArgs(args: StateManagementArgs) { + const services = normalizeServices(args.services); + return { + action: args.action, + has_file_path: Boolean(args.file_path), + services_count: services.length || undefined, + }; +} + +export function normalizeServices(services: StateManagementArgs["services"]): string[] { + const raw = Array.isArray(services) ? services : services?.split(","); + return Array.from(new Set((raw ?? []).map((service) => service.trim()).filter(Boolean))); +} + +export function validateStateManagementArgs(args: StateManagementArgs): ValidationResult { + const services = normalizeServices(args.services); + const filePath = args.file_path?.trim(); + + switch (args.action) { + case "export": { + const destination = filePath || DEFAULT_EXPORT_PATH; + const parent = path.dirname(path.resolve(destination)); + if (!fs.existsSync(parent)) { + return { + error: ResponseBuilder.error( + "Export Path Not Found", + `The parent directory for \`${destination}\` does not exist: \`${parent}\`.` + ), + }; + } + + return { serviceList: services, outputPath: destination }; + } + + case "import": { + if (!filePath) { + return { + error: ResponseBuilder.error( + "Missing File Path", + "The `import` action requires `file_path` pointing to a file previously created by `localstack state export`." + ), + }; + } + if (!fs.existsSync(filePath)) { + return { + error: ResponseBuilder.error( + "Import File Not Found", + `The state file \`${filePath}\` does not exist.` + ), + }; + } + if (services.length > 0) { + return { + error: ResponseBuilder.error( + "Unsupported Service Filter", + "`localstack state import` restores the services contained in the exported state file. Service-level filtering is supported for export, reset, and inspect." + ), + }; + } + return { outputPath: filePath }; + } + + case "reset": { + if (filePath) { + return { + error: ResponseBuilder.error( + "Unsupported File Path", + "The `reset` action does not use `file_path`." + ), + }; + } + return { serviceList: services }; + } + + case "inspect": { + if (filePath) { + return { + error: ResponseBuilder.error( + "Unsupported File Path", + "The `inspect` action reads state from the running LocalStack instance and does not use `file_path`." + ), + }; + } + return { serviceList: services }; + } + + default: + return { + error: ResponseBuilder.error("Unknown Action", `Unsupported action: ${args.action}`), + }; + } +} + +async function handleExport(client: StateManagementApiClient, validation: ValidationResult) { + const result = await client.exportState(validation.serviceList); + if (!result.success) return formatStateApiError("export", result); + + const outputPath = validation.outputPath ?? DEFAULT_EXPORT_PATH; + fs.writeFileSync(outputPath, result.data.content); + return formatExportSuccess(outputPath, result.data, validation.serviceList ?? []); +} + +async function handleImport(client: StateManagementApiClient, validation: ValidationResult) { + const outputPath = validation.outputPath; + if (!outputPath) { + return ResponseBuilder.error("Missing File Path", "The `import` action requires `file_path`."); + } + + const content = fs.readFileSync(outputPath); + const result = await client.importState(content); + if (!result.success) return formatStateApiError("import", result); + + const details = result.data?.trim(); + return ResponseBuilder.markdown( + `## LocalStack State Imported\n\n**File:** \`${outputPath}\`\n\nImported local state from disk using the LocalStack State REST API. Use Cloud Pods when you need remote cloud-backed state snapshots.${details ? `\n\n${details}` : ""}` + ); +} + +async function handleReset(client: StateManagementApiClient, validation: ValidationResult) { + const result = await client.resetState(validation.serviceList); + if (!result.success) return formatStateApiError("reset", result); + + const services = formatServiceList(validation.serviceList ?? []); + return ResponseBuilder.markdown( + `## LocalStack State Reset\n\n${validation.serviceList?.length ? "Selected service state was reset." : "All LocalStack service state was reset."}${services}` + ); +} + +async function handleInspect(client: StateManagementApiClient, validation: ValidationResult) { + const result = await client.inspectState(); + if (!result.success) return formatStateApiError("inspect", result); + + return formatInspectResult(result.data, validation.serviceList ?? []); +} + +function formatExportSuccess(path: string, data: StateExportResult, services: string[]) { + const exportedServices = data.services.length ? data.services : services; + return ResponseBuilder.markdown( + `## LocalStack State Exported\n\n**File:** \`${path}\`\n\n**Bytes written:** ${data.content.length}${formatServiceList(exportedServices)}` + ); +} + +function formatServiceList(services: string[]) { + return services.length + ? `\n\n**Services:** ${services.map((service) => `\`${service}\``).join(", ")}` + : ""; +} + +export function formatInspectResult(data: unknown, services: string[]) { + if (!data) { + return ResponseBuilder.markdown("## LocalStack State Inspect\n\nNo state data returned."); + } + + const filtered = services.length > 0 ? filterInspectServices(data, services) : data; + const servicesSummary = formatServiceList(services); + return ResponseBuilder.markdown( + `## LocalStack State Inspect${servicesSummary}\n\n\`\`\`json\n${JSON.stringify(filtered, null, 2)}\n\`\`\`` + ); +} + +export function filterInspectServices(data: unknown, services: string[]) { + const serviceSet = new Set(services); + if (!data || typeof data !== "object" || Array.isArray(data)) return data; + + return Object.fromEntries( + Object.entries(data as Record).map(([account, details]) => { + if (!details || typeof details !== "object" || Array.isArray(details)) { + return [account, details]; + } + return [ + account, + Object.fromEntries( + Object.entries(details as Record).filter(([service]) => + serviceSet.has(service) + ) + ), + ]; + }) + ); +} + +function formatStateApiError(action: StateManagementArgs["action"], result: ApiResult) { + if (result.success) return ResponseBuilder.error("Unexpected State Management API Result"); + + return ResponseBuilder.error( + "State Management API Error", + `The \`${action}\` action failed${result.statusCode ? ` with HTTP ${result.statusCode}` : ""}.${result.message ? `\n\n${result.message}` : ""}` + ); +} diff --git a/tests/mcp/direct.spec.mjs b/tests/mcp/direct.spec.mjs index 57bb5ca..d4234e4 100644 --- a/tests/mcp/direct.spec.mjs +++ b/tests/mcp/direct.spec.mjs @@ -7,6 +7,7 @@ const EXPECTED_TOOLS = [ "localstack-iam-policy-analyzer", "localstack-chaos-injector", "localstack-cloud-pods", + "localstack-state-management", "localstack-extensions", "localstack-snowflake-client", "localstack-ephemeral-instances",