Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ This server eliminates custom scripts and manual LocalStack management with dire
- 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)
- Launch and manage [Ephemeral Instances](https://docs.localstack.cloud/aws/capabilities/cloud-sandbox/ephemeral-instances/) for remote LocalStack testing workflows.
- Connect AI assistants and dev tools for automated cloud testing workflows.

## Tools Reference
Expand All @@ -29,6 +30,7 @@ This server provides your AI with dedicated tools for managing your LocalStack e
| [`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 |
| [`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 |
| [`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 |
| [`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 |
| [`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 |
| [`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 |

Expand Down
4 changes: 4 additions & 0 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@
"name": "localstack-extensions",
"description": "Install, uninstall, list, and discover LocalStack Extensions from the marketplace"
},
{
"name": "localstack-ephemeral-instances",
"description": "Manage cloud-hosted LocalStack Ephemeral Instances by creating, listing, viewing logs, and deleting instances"
},
{
"name": "localstack-docs",
"description": "Search the LocalStack documentation to find guides, API references, and configuration details"
Expand Down
8 changes: 8 additions & 0 deletions src/core/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ export const TOOL_ARG_ALLOWLIST: Record<string, string[]> = {
"saveParams",
],
"localstack-docs": ["query", "limit"],
"localstack-ephemeral-instances": [
"action",
"name",
"lifetime",
"extension",
"cloudPod",
"envVarKeys",
],
"localstack-extensions": ["action", "name", "source"],
"localstack-iam-policy-analyzer": ["action", "mode"],
"localstack-logs-analysis": ["analysisType", "lines", "service", "operation", "filter"],
Expand Down
296 changes: 296 additions & 0 deletions src/tools/localstack-ephemeral-instances.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
import { z } from "zod";
import { type ToolMetadata, type InferSchema } from "xmcp";
import { runCommand, stripAnsiCodes } from "../core/command-runner";
import { runPreflights, requireLocalStackCli, requireAuthToken } from "../core/preflight";
import { ResponseBuilder } from "../core/response-builder";
import { withToolAnalytics } from "../core/analytics";

export const schema = {
action: z
.enum(["create", "list", "logs", "delete"])
.describe("The Ephemeral Instances action to perform."),
name: z
.string()
.optional()
.describe("Instance name. Required for create, logs, and delete actions."),
lifetime: z
.number()
.int()
.positive()
.optional()
.describe("Lifetime in minutes for create action. Defaults to CLI default when omitted."),
extension: z
.string()
.optional()
.describe(
"Optional extension package to preload for create action. This is passed as EXTENSION_AUTO_INSTALL."
),
cloudPod: z
.string()
.optional()
.describe(
"Optional Cloud Pod name to initialize state for create action. This is passed as CLOUD_POD_NAME."
),
envVars: z
.record(z.string(), z.string())
.optional()
.describe(
"Additional environment variables to pass to the ephemeral instance (create action only), translated to repeated --env KEY=VALUE flags."
),
};

export const metadata: ToolMetadata = {
name: "localstack-ephemeral-instances",
description:
"Manage cloud-hosted LocalStack Ephemeral Instances: create, list, fetch logs, and delete.",
annotations: {
title: "LocalStack Ephemeral Instances",
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
};

export default async function localstackEphemeralInstances({
action,
name,
lifetime,
extension,
cloudPod,
envVars,
}: InferSchema<typeof schema>) {
return withToolAnalytics(
"localstack-ephemeral-instances",
{
action,
name,
lifetime,
extension,
cloudPod,
envVarKeys: envVars ? Object.keys(envVars) : [],
},
async () => {
const authError = requireAuthToken();
if (authError) return authError;

const preflightError = await runPreflights([requireLocalStackCli()]);
if (preflightError) return preflightError;

switch (action) {
case "create":
return await handleCreate({ name, lifetime, extension, cloudPod, envVars });
case "list":
return await handleList();
case "logs":
return await handleLogs({ name });
case "delete":
return await handleDelete({ name });
default:
return ResponseBuilder.error("Unknown action", `Unsupported action: ${action}`);
}
}
);
}

function cleanOutput(stdout: string, stderr: string): { stdout: string; stderr: string; combined: string } {
const cleanStdout = stripAnsiCodes(stdout || "").trim();
const cleanStderr = stripAnsiCodes(stderr || "").trim();
const combined = [cleanStdout, cleanStderr].filter((part) => part.length > 0).join("\n").trim();
return { stdout: cleanStdout, stderr: cleanStderr, combined };
}

function parseJsonFromText(text: string): unknown {
const trimmed = text.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed);
} catch {
const startObject = trimmed.indexOf("{");
const endObject = trimmed.lastIndexOf("}");
if (startObject !== -1 && endObject > startObject) {
const candidate = trimmed.slice(startObject, endObject + 1);
try {
return JSON.parse(candidate);
} catch {
// continue
}
}
const startArray = trimmed.indexOf("[");
const endArray = trimmed.lastIndexOf("]");
if (startArray !== -1 && endArray > startArray) {
const candidate = trimmed.slice(startArray, endArray + 1);
try {
return JSON.parse(candidate);
} catch {
// continue
}
}
return null;
}
}

function formatCreateResponse(payload: Record<string, unknown>): string {
const endpoint = String(payload.endpoint_url ?? "N/A");
const id = String(payload.id ?? "N/A");
const status = String(payload.status ?? "unknown");
const creationTime = String(payload.creation_time ?? "N/A");
const expiryTime = String(payload.expiry_time ?? "N/A");

return `## Ephemeral Instance Created

- **ID:** ${id}
- **Status:** ${status}
- **Endpoint URL:** ${endpoint}
- **Creation Time:** ${creationTime}
- **Expiry Time:** ${expiryTime}

\`\`\`json
${JSON.stringify(payload, null, 2)}
\`\`\`

Use this endpoint with your tools, for example:
\`aws --endpoint-url=${endpoint} s3 ls\``;
}

async function handleCreate({
name,
lifetime,
extension,
cloudPod,
envVars,
}: {
name?: string;
lifetime?: number;
extension?: string;
cloudPod?: string;
envVars?: Record<string, string>;
}) {
if (!name?.trim()) {
return ResponseBuilder.error(
"Missing Required Parameter",
"The `create` action requires the `name` parameter."
);
}

const args = ["ephemeral", "create", "--name", name.trim()];
if (lifetime !== undefined) {
args.push("--lifetime", String(lifetime));
}

const mergedEnvVars: Record<string, string> = { ...(envVars || {}) };
if (extension) {
mergedEnvVars.EXTENSION_AUTO_INSTALL = extension;
}
if (cloudPod) {
mergedEnvVars.CLOUD_POD_NAME = cloudPod;
}

for (const [key, value] of Object.entries(mergedEnvVars)) {
if (!key || key.includes("=")) {
return ResponseBuilder.error(
"Invalid Environment Variable Key",
`Invalid env var key '${key}'. Keys must be non-empty and cannot contain '='.`
);
}
args.push("--env", `${key}=${value}`);
}

const result = await runCommand("localstack", args, {
env: { ...process.env },
timeout: 180000,
});
const cleaned = cleanOutput(result.stdout, result.stderr);

if (result.exitCode !== 0) {
return ResponseBuilder.error(
"Create Failed",
cleaned.combined || "Failed to create ephemeral instance."
);
}

const parsed = parseJsonFromText(cleaned.stdout) || parseJsonFromText(cleaned.combined);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return ResponseBuilder.markdown(formatCreateResponse(parsed as Record<string, unknown>));
}

return ResponseBuilder.markdown(
`## Ephemeral Instance Created\n\n${cleaned.combined || "Instance created successfully."}`
);
}

async function handleList() {
const result = await runCommand("localstack", ["ephemeral", "list"], {
env: { ...process.env },
timeout: 120000,
});
const cleaned = cleanOutput(result.stdout, result.stderr);

if (result.exitCode !== 0) {
return ResponseBuilder.error("List Failed", cleaned.combined || "Failed to list ephemeral instances.");
}

const parsed = parseJsonFromText(cleaned.stdout) || parseJsonFromText(cleaned.combined);
if (parsed === null) {
return ResponseBuilder.markdown(
`## Ephemeral Instances\n\n\`\`\`\n${cleaned.combined || "No instances found."}\n\`\`\``
);
}

return ResponseBuilder.markdown(
`## Ephemeral Instances\n\n\`\`\`json\n${JSON.stringify(parsed, null, 2)}\n\`\`\``
);
}

async function handleLogs({ name }: { name?: string }) {
if (!name?.trim()) {
return ResponseBuilder.error(
"Missing Required Parameter",
"The `logs` action requires the `name` parameter."
);
}

const result = await runCommand("localstack", ["ephemeral", "logs", "--name", name.trim()], {
env: { ...process.env },
timeout: 180000,
});
const cleaned = cleanOutput(result.stdout, result.stderr);

if (result.exitCode !== 0) {
return ResponseBuilder.error(
"Logs Failed",
cleaned.combined || `Failed to fetch logs for instance '${name}'.`
);
}

if (!cleaned.combined) {
return ResponseBuilder.markdown(`No logs available for ephemeral instance '${name}'.`);
}

return ResponseBuilder.markdown(
`## Ephemeral Instance Logs: ${name}\n\n\`\`\`\n${cleaned.combined}\n\`\`\``
);
}

async function handleDelete({ name }: { name?: string }) {
if (!name?.trim()) {
return ResponseBuilder.error(
"Missing Required Parameter",
"The `delete` action requires the `name` parameter."
);
}

const result = await runCommand("localstack", ["ephemeral", "delete", "--name", name.trim()], {
env: { ...process.env },
timeout: 120000,
});
const cleaned = cleanOutput(result.stdout, result.stderr);

if (result.exitCode !== 0) {
return ResponseBuilder.error(
"Delete Failed",
cleaned.combined || `Failed to delete ephemeral instance '${name}'.`
);
}

return ResponseBuilder.markdown(cleaned.combined || `Successfully deleted instance: ${name} ✅`);
}
1 change: 1 addition & 0 deletions tests/mcp/direct.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const EXPECTED_TOOLS = [
"localstack-chaos-injector",
"localstack-cloud-pods",
"localstack-extensions",
"localstack-ephemeral-instances",
"localstack-aws-client",
"localstack-docs",
];
Expand Down
Loading