Skip to content

Commit e794188

Browse files
authored
add localstack ephemeral instances tool (#20)
1 parent 668b6f4 commit e794188

5 files changed

Lines changed: 311 additions & 0 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ This server eliminates custom scripts and manual LocalStack management with dire
1414
- Inject chaos faults and network effects into LocalStack to test system resilience. (requires active license)
1515
- Manage LocalStack state snapshots via [Cloud Pods](https://docs.localstack.cloud/aws/capabilities/state-management/cloud-pods/) for development workflows. (requires active license)
1616
- Install, remove, list, and discover [LocalStack Extensions](https://docs.localstack.cloud/aws/capabilities/extensions/) from the marketplace. (requires active license)
17+
- Launch and manage [Ephemeral Instances](https://docs.localstack.cloud/aws/capabilities/cloud-sandbox/ephemeral-instances/) for remote LocalStack testing workflows.
1718
- Connect AI assistants and dev tools for automated cloud testing workflows.
1819

1920
## Tools Reference
@@ -32,6 +33,7 @@ This server provides your AI with dedicated tools for managing your LocalStack e
3233
| [`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<br/>- Configure network latency effects<br/>- Comprehensive fault targeting by service, region, and operation<br/>- Built-in workflow guidance for chaos experiments<br/>- Requires a valid LocalStack Auth Token |
3334
| [`localstack-cloud-pods`](./src/tools/localstack-cloud-pods.ts) | Manages LocalStack state snapshots for development workflows | - Save current state as Cloud Pods<br/>- Load previously saved Cloud Pods instantly<br/>- Delete Cloud Pods or reset to a clean state<br/>- Requires a valid LocalStack Auth Token |
3435
| [`localstack-extensions`](./src/tools/localstack-extensions.ts) | Installs, uninstalls, lists, and discovers LocalStack Extensions | - Manage installed extensions via CLI actions (`list`, `install`, `uninstall`)<br/>- Browse the LocalStack Extensions marketplace (`available`)<br/>- Requires a valid LocalStack Auth Token support |
36+
| [`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<br/>- List available ephemeral instances, fetch logs, and delete instances<br/>- Supports lifetime, extension preload, Cloud Pod preload, and custom env vars on create<br/>- Requires a valid LocalStack Auth Token and LocalStack CLI |
3537
| [`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<br/>- Sanitizes commands to block shell chaining<br/>- Auto-detects LocalStack coverage errors and links to docs |
3638
| [`localstack-docs`](./src/tools/localstack-docs.ts) | Searches LocalStack documentation through CrawlChat | - Queries LocalStack docs through a public CrawlChat collection<br/>- Returns focused snippets with source links only<br/>- Helps answer coverage, configuration, and setup questions without requiring LocalStack runtime |
3739

manifest.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@
5151
"name": "localstack-extensions",
5252
"description": "Install, uninstall, list, and discover LocalStack Extensions from the marketplace"
5353
},
54+
{
55+
"name": "localstack-ephemeral-instances",
56+
"description": "Manage cloud-hosted LocalStack Ephemeral Instances by creating, listing, viewing logs, and deleting instances"
57+
},
5458
{
5559
"name": "localstack-docs",
5660
"description": "Search the LocalStack documentation to find guides, API references, and configuration details"

src/core/analytics.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ export const TOOL_ARG_ALLOWLIST: Record<string, string[]> = {
3030
"saveParams",
3131
],
3232
"localstack-docs": ["query", "limit"],
33+
"localstack-ephemeral-instances": [
34+
"action",
35+
"name",
36+
"lifetime",
37+
"extension",
38+
"cloudPod",
39+
"envVarKeys",
40+
],
3341
"localstack-extensions": ["action", "name", "source"],
3442
"localstack-iam-policy-analyzer": ["action", "mode"],
3543
"localstack-logs-analysis": ["analysisType", "lines", "service", "operation", "filter"],
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
import { z } from "zod";
2+
import { type ToolMetadata, type InferSchema } from "xmcp";
3+
import { runCommand, stripAnsiCodes } from "../core/command-runner";
4+
import { runPreflights, requireLocalStackCli, requireAuthToken } from "../core/preflight";
5+
import { ResponseBuilder } from "../core/response-builder";
6+
import { withToolAnalytics } from "../core/analytics";
7+
8+
export const schema = {
9+
action: z
10+
.enum(["create", "list", "logs", "delete"])
11+
.describe("The Ephemeral Instances action to perform."),
12+
name: z
13+
.string()
14+
.optional()
15+
.describe("Instance name. Required for create, logs, and delete actions."),
16+
lifetime: z
17+
.number()
18+
.int()
19+
.positive()
20+
.optional()
21+
.describe("Lifetime in minutes for create action. Defaults to CLI default when omitted."),
22+
extension: z
23+
.string()
24+
.optional()
25+
.describe(
26+
"Optional extension package to preload for create action. This is passed as EXTENSION_AUTO_INSTALL."
27+
),
28+
cloudPod: z
29+
.string()
30+
.optional()
31+
.describe(
32+
"Optional Cloud Pod name to initialize state for create action. This is passed as CLOUD_POD_NAME."
33+
),
34+
envVars: z
35+
.record(z.string(), z.string())
36+
.optional()
37+
.describe(
38+
"Additional environment variables to pass to the ephemeral instance (create action only), translated to repeated --env KEY=VALUE flags."
39+
),
40+
};
41+
42+
export const metadata: ToolMetadata = {
43+
name: "localstack-ephemeral-instances",
44+
description:
45+
"Manage cloud-hosted LocalStack Ephemeral Instances: create, list, fetch logs, and delete.",
46+
annotations: {
47+
title: "LocalStack Ephemeral Instances",
48+
readOnlyHint: false,
49+
destructiveHint: true,
50+
idempotentHint: false,
51+
},
52+
};
53+
54+
export default async function localstackEphemeralInstances({
55+
action,
56+
name,
57+
lifetime,
58+
extension,
59+
cloudPod,
60+
envVars,
61+
}: InferSchema<typeof schema>) {
62+
return withToolAnalytics(
63+
"localstack-ephemeral-instances",
64+
{
65+
action,
66+
name,
67+
lifetime,
68+
extension,
69+
cloudPod,
70+
envVarKeys: envVars ? Object.keys(envVars) : [],
71+
},
72+
async () => {
73+
const authError = requireAuthToken();
74+
if (authError) return authError;
75+
76+
const preflightError = await runPreflights([requireLocalStackCli()]);
77+
if (preflightError) return preflightError;
78+
79+
switch (action) {
80+
case "create":
81+
return await handleCreate({ name, lifetime, extension, cloudPod, envVars });
82+
case "list":
83+
return await handleList();
84+
case "logs":
85+
return await handleLogs({ name });
86+
case "delete":
87+
return await handleDelete({ name });
88+
default:
89+
return ResponseBuilder.error("Unknown action", `Unsupported action: ${action}`);
90+
}
91+
}
92+
);
93+
}
94+
95+
function cleanOutput(stdout: string, stderr: string): { stdout: string; stderr: string; combined: string } {
96+
const cleanStdout = stripAnsiCodes(stdout || "").trim();
97+
const cleanStderr = stripAnsiCodes(stderr || "").trim();
98+
const combined = [cleanStdout, cleanStderr].filter((part) => part.length > 0).join("\n").trim();
99+
return { stdout: cleanStdout, stderr: cleanStderr, combined };
100+
}
101+
102+
function parseJsonFromText(text: string): unknown {
103+
const trimmed = text.trim();
104+
if (!trimmed) return null;
105+
try {
106+
return JSON.parse(trimmed);
107+
} catch {
108+
const startObject = trimmed.indexOf("{");
109+
const endObject = trimmed.lastIndexOf("}");
110+
if (startObject !== -1 && endObject > startObject) {
111+
const candidate = trimmed.slice(startObject, endObject + 1);
112+
try {
113+
return JSON.parse(candidate);
114+
} catch {
115+
// continue
116+
}
117+
}
118+
const startArray = trimmed.indexOf("[");
119+
const endArray = trimmed.lastIndexOf("]");
120+
if (startArray !== -1 && endArray > startArray) {
121+
const candidate = trimmed.slice(startArray, endArray + 1);
122+
try {
123+
return JSON.parse(candidate);
124+
} catch {
125+
// continue
126+
}
127+
}
128+
return null;
129+
}
130+
}
131+
132+
function formatCreateResponse(payload: Record<string, unknown>): string {
133+
const endpoint = String(payload.endpoint_url ?? "N/A");
134+
const id = String(payload.id ?? "N/A");
135+
const status = String(payload.status ?? "unknown");
136+
const creationTime = String(payload.creation_time ?? "N/A");
137+
const expiryTime = String(payload.expiry_time ?? "N/A");
138+
139+
return `## Ephemeral Instance Created
140+
141+
- **ID:** ${id}
142+
- **Status:** ${status}
143+
- **Endpoint URL:** ${endpoint}
144+
- **Creation Time:** ${creationTime}
145+
- **Expiry Time:** ${expiryTime}
146+
147+
\`\`\`json
148+
${JSON.stringify(payload, null, 2)}
149+
\`\`\`
150+
151+
Use this endpoint with your tools, for example:
152+
\`aws --endpoint-url=${endpoint} s3 ls\``;
153+
}
154+
155+
async function handleCreate({
156+
name,
157+
lifetime,
158+
extension,
159+
cloudPod,
160+
envVars,
161+
}: {
162+
name?: string;
163+
lifetime?: number;
164+
extension?: string;
165+
cloudPod?: string;
166+
envVars?: Record<string, string>;
167+
}) {
168+
if (!name?.trim()) {
169+
return ResponseBuilder.error(
170+
"Missing Required Parameter",
171+
"The `create` action requires the `name` parameter."
172+
);
173+
}
174+
175+
const args = ["ephemeral", "create", "--name", name.trim()];
176+
if (lifetime !== undefined) {
177+
args.push("--lifetime", String(lifetime));
178+
}
179+
180+
const mergedEnvVars: Record<string, string> = { ...(envVars || {}) };
181+
if (extension) {
182+
mergedEnvVars.EXTENSION_AUTO_INSTALL = extension;
183+
}
184+
if (cloudPod) {
185+
mergedEnvVars.CLOUD_POD_NAME = cloudPod;
186+
}
187+
188+
for (const [key, value] of Object.entries(mergedEnvVars)) {
189+
if (!key || key.includes("=")) {
190+
return ResponseBuilder.error(
191+
"Invalid Environment Variable Key",
192+
`Invalid env var key '${key}'. Keys must be non-empty and cannot contain '='.`
193+
);
194+
}
195+
args.push("--env", `${key}=${value}`);
196+
}
197+
198+
const result = await runCommand("localstack", args, {
199+
env: { ...process.env },
200+
timeout: 180000,
201+
});
202+
const cleaned = cleanOutput(result.stdout, result.stderr);
203+
204+
if (result.exitCode !== 0) {
205+
return ResponseBuilder.error(
206+
"Create Failed",
207+
cleaned.combined || "Failed to create ephemeral instance."
208+
);
209+
}
210+
211+
const parsed = parseJsonFromText(cleaned.stdout) || parseJsonFromText(cleaned.combined);
212+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
213+
return ResponseBuilder.markdown(formatCreateResponse(parsed as Record<string, unknown>));
214+
}
215+
216+
return ResponseBuilder.markdown(
217+
`## Ephemeral Instance Created\n\n${cleaned.combined || "Instance created successfully."}`
218+
);
219+
}
220+
221+
async function handleList() {
222+
const result = await runCommand("localstack", ["ephemeral", "list"], {
223+
env: { ...process.env },
224+
timeout: 120000,
225+
});
226+
const cleaned = cleanOutput(result.stdout, result.stderr);
227+
228+
if (result.exitCode !== 0) {
229+
return ResponseBuilder.error("List Failed", cleaned.combined || "Failed to list ephemeral instances.");
230+
}
231+
232+
const parsed = parseJsonFromText(cleaned.stdout) || parseJsonFromText(cleaned.combined);
233+
if (parsed === null) {
234+
return ResponseBuilder.markdown(
235+
`## Ephemeral Instances\n\n\`\`\`\n${cleaned.combined || "No instances found."}\n\`\`\``
236+
);
237+
}
238+
239+
return ResponseBuilder.markdown(
240+
`## Ephemeral Instances\n\n\`\`\`json\n${JSON.stringify(parsed, null, 2)}\n\`\`\``
241+
);
242+
}
243+
244+
async function handleLogs({ name }: { name?: string }) {
245+
if (!name?.trim()) {
246+
return ResponseBuilder.error(
247+
"Missing Required Parameter",
248+
"The `logs` action requires the `name` parameter."
249+
);
250+
}
251+
252+
const result = await runCommand("localstack", ["ephemeral", "logs", "--name", name.trim()], {
253+
env: { ...process.env },
254+
timeout: 180000,
255+
});
256+
const cleaned = cleanOutput(result.stdout, result.stderr);
257+
258+
if (result.exitCode !== 0) {
259+
return ResponseBuilder.error(
260+
"Logs Failed",
261+
cleaned.combined || `Failed to fetch logs for instance '${name}'.`
262+
);
263+
}
264+
265+
if (!cleaned.combined) {
266+
return ResponseBuilder.markdown(`No logs available for ephemeral instance '${name}'.`);
267+
}
268+
269+
return ResponseBuilder.markdown(
270+
`## Ephemeral Instance Logs: ${name}\n\n\`\`\`\n${cleaned.combined}\n\`\`\``
271+
);
272+
}
273+
274+
async function handleDelete({ name }: { name?: string }) {
275+
if (!name?.trim()) {
276+
return ResponseBuilder.error(
277+
"Missing Required Parameter",
278+
"The `delete` action requires the `name` parameter."
279+
);
280+
}
281+
282+
const result = await runCommand("localstack", ["ephemeral", "delete", "--name", name.trim()], {
283+
env: { ...process.env },
284+
timeout: 120000,
285+
});
286+
const cleaned = cleanOutput(result.stdout, result.stderr);
287+
288+
if (result.exitCode !== 0) {
289+
return ResponseBuilder.error(
290+
"Delete Failed",
291+
cleaned.combined || `Failed to delete ephemeral instance '${name}'.`
292+
);
293+
}
294+
295+
return ResponseBuilder.markdown(cleaned.combined || `Successfully deleted instance: ${name} ✅`);
296+
}

tests/mcp/direct.spec.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const EXPECTED_TOOLS = [
88
"localstack-chaos-injector",
99
"localstack-cloud-pods",
1010
"localstack-extensions",
11+
"localstack-ephemeral-instances",
1112
"localstack-aws-client",
1213
"localstack-docs",
1314
];

0 commit comments

Comments
 (0)