Skip to content

Commit 631613f

Browse files
authored
New MCP Tool: State Management (Export & Import) (#29)
1 parent c21f660 commit 631613f

9 files changed

Lines changed: 591 additions & 37 deletions

File tree

README.md

Lines changed: 21 additions & 19 deletions
Large diffs are not rendered by default.

manifest.json

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,11 @@
4545
},
4646
{
4747
"name": "localstack-cloud-pods",
48-
"description": "Manages LocalStack state snapshots (Cloud Pods) for saving, loading, deleting, and resetting"
48+
"description": "Manages remote LocalStack Cloud Pods for saving, loading, and deleting cloud-backed state snapshots"
49+
},
50+
{
51+
"name": "localstack-state-management",
52+
"description": "Exports, imports, resets, and inspects LocalStack state with local file-based workflows on disk"
4953
},
5054
{
5155
"name": "localstack-extensions",
@@ -176,9 +180,33 @@
176180
},
177181
{
178182
"name": "cloud-pods",
179-
"description": "Manage LocalStack state snapshots (Cloud Pods) for saving, loading, deleting, and resetting",
183+
"description": "Manage remote LocalStack Cloud Pods for saving, loading, and deleting cloud-backed snapshots",
180184
"arguments": ["action", "pod_name"],
181-
"text": "Please ${arguments.action} Cloud Pod in the LocalStack container with the pod name ${arguments.pod_name}."
185+
"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."
186+
},
187+
{
188+
"name": "localstack-state-export",
189+
"description": "Export LocalStack state to a local file on disk",
190+
"arguments": ["file_path", "services"],
191+
"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."
192+
},
193+
{
194+
"name": "localstack-state-import",
195+
"description": "Import LocalStack state from a local file on disk",
196+
"arguments": ["file_path"],
197+
"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."
198+
},
199+
{
200+
"name": "localstack-state-inspect",
201+
"description": "Inspect current LocalStack state locally",
202+
"arguments": ["services"],
203+
"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."
204+
},
205+
{
206+
"name": "localstack-state-reset",
207+
"description": "Reset LocalStack state locally",
208+
"arguments": ["services"],
209+
"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."
182210
},
183211
{
184212
"name": "extensions-list",

src/core/analytics.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const TOOL_ARG_ALLOWLIST: Record<string, string[]> = {
4040
],
4141
"localstack-chaos-injector": ["action", "rules_count", "latency_ms"],
4242
"localstack-cloud-pods": ["action", "pod_name"],
43+
"localstack-state-management": ["action", "has_file_path", "services_count"],
4344
"localstack-deployer": [
4445
"action",
4546
"projectType",

src/lib/localstack/license-checker.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export enum ProFeature {
1111
EXTENSIONS = "localstack.platform.plugin/extensions",
1212
REPLICATOR = "localstack.platform.plugin/replicator",
1313
SNOWFLAKE = "localstack.aws.provider/snowflake:pro",
14+
STATE_MANAGEMENT = "localstack.platform.plugin/snapshot",
1415
}
1516

1617
export interface LicenseCheckResult {

src/lib/localstack/localstack.client.ts

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { LOCALSTACK_BASE_URL } from "../../core/config";
12
import { httpClient, HttpError } from "../../core/http-client";
23

34
export type ApiResult<T> =
@@ -58,6 +59,13 @@ export interface AppInspectorSetStatusResponse {
5859
export type AppInspectorStatus = "enabled" | "disabled";
5960
export type AppInspectorQuery = Record<string, string | number | undefined>;
6061

62+
export interface StateExportResult {
63+
content: Buffer;
64+
services: string[];
65+
size: number;
66+
contentLength?: number;
67+
}
68+
6169
// Chaos API Client
6270
export class ChaosApiClient {
6371
private async makeRequest(
@@ -184,8 +192,116 @@ export class CloudPodsApiClient {
184192
deletePod(podName: string) {
185193
return this.makeRequest(`/_localstack/pods/${encodeURIComponent(podName)}`, "DELETE", true, {});
186194
}
187-
resetState() {
188-
return this.makeRequest("/_localstack/state/reset", "POST", false, {});
195+
}
196+
197+
// Local file-based State Management API Client
198+
export class StateManagementApiClient {
199+
private async requestResponse(
200+
endpoint: string,
201+
options: RequestInit = {}
202+
): Promise<ApiResult<Response>> {
203+
try {
204+
const response = await fetch(`${LOCALSTACK_BASE_URL}${endpoint}`, options);
205+
if (!response.ok) {
206+
return {
207+
success: false,
208+
message: await response.text(),
209+
statusCode: response.status,
210+
};
211+
}
212+
return { success: true, data: response };
213+
} catch (error) {
214+
return {
215+
success: false,
216+
message: `Failed to communicate with LocalStack State Management API: ${error instanceof Error ? error.message : "Unknown error"}`,
217+
};
218+
}
219+
}
220+
221+
private serviceQuery(services?: string[]) {
222+
if (!services || services.length === 0) return "";
223+
return `?services=${encodeURIComponent(services.join(","))}`;
224+
}
225+
226+
async exportState(services?: string[]): Promise<ApiResult<StateExportResult>> {
227+
const result = await this.requestResponse(
228+
`/_localstack/pods/state${this.serviceQuery(services)}`,
229+
{
230+
method: "GET",
231+
}
232+
);
233+
if (!result.success) return result;
234+
235+
const response = result.data;
236+
const content = Buffer.from(await response.arrayBuffer());
237+
const exportedServices = (response.headers.get("x-localstack-pod-services") ?? "")
238+
.split(",")
239+
.map((service) => service.trim())
240+
.filter(Boolean);
241+
const size = Number(response.headers.get("x-localstack-pod-size") ?? content.length);
242+
const contentLength = Number(response.headers.get("content-length") ?? content.length);
243+
244+
return {
245+
success: true,
246+
data: {
247+
content,
248+
services: exportedServices,
249+
size: Number.isNaN(size) ? content.length : size,
250+
contentLength: Number.isNaN(contentLength) ? undefined : contentLength,
251+
},
252+
};
253+
}
254+
255+
async importState(content: Buffer): Promise<ApiResult<string>> {
256+
const body = content.buffer.slice(
257+
content.byteOffset,
258+
content.byteOffset + content.byteLength
259+
) as ArrayBuffer;
260+
const result = await this.requestResponse("/_localstack/pods", {
261+
method: "POST",
262+
headers: { "Content-Type": "application/octet-stream" },
263+
body,
264+
});
265+
if (!result.success) return result;
266+
return { success: true, data: await result.data.text() };
267+
}
268+
269+
async resetState(services?: string[]): Promise<ApiResult<void>> {
270+
if (!services || services.length === 0) {
271+
const result = await this.requestResponse("/_localstack/state/reset", { method: "POST" });
272+
return result.success ? { success: true, data: undefined } : result;
273+
}
274+
275+
for (const service of services) {
276+
const result = await this.requestResponse(
277+
`/_localstack/state/${encodeURIComponent(service)}/reset`,
278+
{ method: "POST" }
279+
);
280+
if (!result.success) return result;
281+
}
282+
283+
return { success: true, data: undefined };
284+
}
285+
286+
async inspectState(): Promise<ApiResult<unknown>> {
287+
try {
288+
const data = await httpClient.request<unknown>("/_localstack/pods/state/metamodel", {
289+
method: "GET",
290+
});
291+
return { success: true, data };
292+
} catch (error) {
293+
if (error instanceof HttpError) {
294+
return {
295+
success: false,
296+
message: error.body || error.message,
297+
statusCode: error.status,
298+
};
299+
}
300+
return {
301+
success: false,
302+
message: `Failed to communicate with LocalStack State Management API: ${error instanceof Error ? error.message : "Unknown error"}`,
303+
};
304+
}
189305
}
190306
}
191307

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import fs from "fs";
2+
import os from "os";
3+
import path from "path";
4+
import {
5+
buildStateAnalyticsArgs,
6+
filterInspectServices,
7+
formatInspectResult,
8+
normalizeServices,
9+
validateStateManagementArgs,
10+
} from "../../tools/localstack-state-management";
11+
12+
describe("localstack-state-management", () => {
13+
describe("normalizeServices", () => {
14+
it("accepts comma-delimited and array service inputs", () => {
15+
expect(normalizeServices("s3, lambda, s3")).toEqual(["s3", "lambda"]);
16+
expect(normalizeServices(["sqs", "sns", "sqs"])).toEqual(["sqs", "sns"]);
17+
});
18+
});
19+
20+
describe("validateStateManagementArgs", () => {
21+
it("validates export with file path and services", () => {
22+
const filePath = path.join(os.tmpdir(), "ls-state-export-test.zip");
23+
const result = validateStateManagementArgs({
24+
action: "export",
25+
file_path: filePath,
26+
services: ["s3", "lambda"],
27+
} as any);
28+
29+
expect(result.error).toBeUndefined();
30+
expect(result.outputPath).toBe(filePath);
31+
expect(result.serviceList).toEqual(["s3", "lambda"]);
32+
});
33+
34+
it("requires an existing file for import and rejects service filters", () => {
35+
const filePath = path.join(os.tmpdir(), "ls-state-import-test.zip");
36+
fs.writeFileSync(filePath, "state");
37+
38+
try {
39+
const result = validateStateManagementArgs({
40+
action: "import",
41+
file_path: filePath,
42+
services: "s3",
43+
} as any);
44+
45+
expect(result.error?.content[0].text).toContain("Unsupported Service Filter");
46+
} finally {
47+
fs.unlinkSync(filePath);
48+
}
49+
});
50+
51+
it("validates service-level reset", () => {
52+
const result = validateStateManagementArgs({
53+
action: "reset",
54+
services: ["s3", "sqs"],
55+
} as any);
56+
57+
expect(result.error).toBeUndefined();
58+
expect(result.serviceList).toEqual(["s3", "sqs"]);
59+
});
60+
61+
it("validates inspect without requiring a file path", () => {
62+
const result = validateStateManagementArgs({
63+
action: "inspect",
64+
} as any);
65+
66+
expect(result.error).toBeUndefined();
67+
expect(result.serviceList).toEqual([]);
68+
});
69+
});
70+
71+
describe("buildStateAnalyticsArgs", () => {
72+
it("does not include raw file paths or service names", () => {
73+
const analyticsArgs = buildStateAnalyticsArgs({
74+
action: "export",
75+
file_path: "/tmp/customer-state.zip",
76+
services: ["s3", "lambda"],
77+
} as any);
78+
79+
expect(analyticsArgs).toEqual({
80+
action: "export",
81+
has_file_path: true,
82+
services_count: 2,
83+
});
84+
expect(JSON.stringify(analyticsArgs)).not.toContain("/tmp/customer-state.zip");
85+
expect(JSON.stringify(analyticsArgs)).not.toContain("lambda");
86+
});
87+
});
88+
89+
describe("filterInspectServices", () => {
90+
it("filters account-scoped inspect data to selected services", () => {
91+
const filtered = filterInspectServices(
92+
{
93+
"000000000000": {
94+
s3: { buckets: ["test"] },
95+
lambda: { functions: ["fn"] },
96+
sqs: { queues: ["q"] },
97+
},
98+
},
99+
["s3", "sqs"]
100+
);
101+
102+
expect(filtered).toEqual({
103+
"000000000000": {
104+
s3: { buckets: ["test"] },
105+
sqs: { queues: ["q"] },
106+
},
107+
});
108+
});
109+
});
110+
111+
describe("formatInspectResult", () => {
112+
it("returns filtered JSON markdown for selected services", () => {
113+
const result = formatInspectResult(
114+
{
115+
"000000000000": {
116+
s3: { buckets: ["test"] },
117+
lambda: { functions: ["fn"] },
118+
},
119+
},
120+
["s3"]
121+
);
122+
123+
expect(result.content[0].text).toContain("LocalStack State Inspect");
124+
expect(result.content[0].text).toContain('"s3"');
125+
expect(result.content[0].text).not.toContain('"lambda"');
126+
});
127+
});
128+
});

src/tools/localstack-cloud-pods.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ import { withToolAnalytics } from "../core/analytics";
1414

1515
// Define the schema for tool parameters
1616
export const schema = {
17-
action: z.enum(["save", "load", "delete", "reset"]).describe("The Cloud Pods action to perform."),
17+
action: z
18+
.enum(["save", "load", "delete"])
19+
.describe(
20+
"The Cloud Pods action to perform."
21+
),
1822

1923
pod_name: z
2024
.string()
@@ -34,7 +38,7 @@ export const schema = {
3438
// Define tool metadata
3539
export const metadata: ToolMetadata = {
3640
name: "localstack-cloud-pods",
37-
description: "Manages LocalStack Cloud Pods with following actions: save, load, delete, reset",
41+
description: "Manages remote LocalStack Cloud Pods with following actions: save, load, delete",
3842
annotations: {
3943
title: "LocalStack Cloud Pods",
4044
readOnlyHint: false,
@@ -127,17 +131,6 @@ export default async function localstackCloudPods({
127131
return ResponseBuilder.success(`Cloud Pod '**${pod_name}**' has been permanently deleted.`);
128132
}
129133

130-
case "reset": {
131-
const result = await client.resetState();
132-
if (!result.success) {
133-
return ResponseBuilder.error("Cloud Pods Error", result.message);
134-
}
135-
136-
return ResponseBuilder.markdown(
137-
"⚠️ LocalStack state has been reset successfully. **All unsaved state has been permanently lost.**"
138-
);
139-
}
140-
141134
default:
142135
return ResponseBuilder.error("Unknown action", `Unsupported action: ${action}`);
143136
}

0 commit comments

Comments
 (0)