diff --git a/README.md b/README.md
index ea7d7ef..75a3d04 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@ This server eliminates custom scripts and manual LocalStack management with dire
- 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.
+- 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)
- Connect AI assistants and dev tools for automated cloud testing workflows.
## Tools Reference
@@ -25,25 +26,27 @@ 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 |
+| 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-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 |
+| [`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 |
## Installation
-| Editor | Installation |
-| :------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| **Cursor** | [](https://cursor.com/en/install-mcp?name=localstack-mcp-server&config=eyJjb21tYW5kIjoibnB4IC15IEBsb2NhbHN0YWNrL2xvY2Fsc3RhY2stbWNwLXNlcnZlciJ9) |
+| Editor | Installation |
+| :--------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Cursor** | [](https://cursor.com/en/install-mcp?name=localstack-mcp-server&config=eyJjb21tYW5kIjoibnB4IC15IEBsb2NhbHN0YWNrL2xvY2Fsc3RhY2stbWNwLXNlcnZlciJ9) |
+
For other MCP Clients, refer to the [configuration guide](#configuration).
### Prerequisites
@@ -83,7 +86,7 @@ If you installed from source, change `command` and `args` to point to your local
"args": ["/path/to/your/localstack-mcp-server/dist/stdio.js"],
"env": {
"LOCALSTACK_AUTH_TOKEN": ""
- }
+ }
}
}
}
@@ -91,14 +94,15 @@ If you installed from source, change `command` and `args` to point to your local
## LocalStack Configuration
-| Variable Name | Description | Default Value |
-| ------------- | ----------- | ------------- |
-| `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_DEFAULT_REGION` (**required for AWS Replicator tool**) | Source AWS region used by AWS Replicator | None |
+| Variable Name | Description | Default Value |
+| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- |
+| `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` |
+| `APP_INSPECTOR` | Set to `1` in the LocalStack container environment to enable App Inspector by default across restarts. The MCP tool can also toggle App Inspector at runtime with `set-status`. | `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_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.
diff --git a/manifest.json b/manifest.json
index cc2e598..192d2b5 100644
--- a/manifest.json
+++ b/manifest.json
@@ -70,6 +70,10 @@
{
"name": "localstack-docs",
"description": "Search the LocalStack documentation to find guides, API references, and configuration details"
+ },
+ {
+ "name": "localstack-app-inspector",
+ "description": "Inspect LocalStack application traces, spans, events, payload metadata, and IAM policy evaluations"
}
],
"prompts": [
@@ -153,6 +157,23 @@
"arguments": ["job_id"],
"text": "Check the AWS Replicator job status for ${arguments.job_id}."
},
+ {
+ "name": "app-inspector-enable",
+ "description": "Enable App Inspector on the running LocalStack instance",
+ "text": "Enable LocalStack App Inspector so I can capture traces, spans, events, payload metadata, and IAM policy evaluations for the next AWS workload I run locally."
+ },
+ {
+ "name": "app-inspector-inspect-application-flow",
+ "description": "Inspect recent LocalStack application flows with App Inspector",
+ "arguments": ["description"],
+ "text": "Use LocalStack App Inspector to investigate ${arguments.description}. Check whether App Inspector is enabled, list recent traces, identify relevant spans, then inspect events and IAM policy evaluation events for the AWS service flow."
+ },
+ {
+ "name": "app-inspector-service-traces",
+ "description": "Find App Inspector traces for a specific AWS service",
+ "arguments": ["service_name"],
+ "text": "List recent App Inspector traces for the ${arguments.service_name} service, then help me drill into spans and events that explain what happened in LocalStack."
+ },
{
"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 ac33b9c..5e4e0d3 100644
--- a/server.json
+++ b/server.json
@@ -87,6 +87,13 @@
"format": "string",
"is_secret": false,
"name": "AWS_REPLICATOR_SOURCE_ENDPOINT_URL"
+ },
+ {
+ "description": "Set to 1 in the LocalStack container environment to enable App Inspector by default across restarts. The MCP tool can also toggle App Inspector at runtime.",
+ "is_required": false,
+ "format": "string",
+ "is_secret": false,
+ "name": "APP_INSPECTOR"
}
]
}
diff --git a/src/core/analytics.ts b/src/core/analytics.ts
index 6db7a29..72698e4 100644
--- a/src/core/analytics.ts
+++ b/src/core/analytics.ts
@@ -26,6 +26,18 @@ export const TOOL_ARG_ALLOWLIST: Record = {
"has_resource_identifier",
"has_resource_arn",
],
+ "localstack-app-inspector": [
+ "action",
+ "target_status",
+ "has_trace_id",
+ "has_span_id",
+ "has_event_id",
+ "trace_ids_count",
+ "span_ids_count",
+ "limit",
+ "has_pagination_token",
+ "filter_keys",
+ ],
"localstack-chaos-injector": ["action", "rules_count", "latency_ms"],
"localstack-cloud-pods": ["action", "pod_name"],
"localstack-deployer": [
diff --git a/src/lib/localstack/app-inspector.logic.test.ts b/src/lib/localstack/app-inspector.logic.test.ts
new file mode 100644
index 0000000..f5aad15
--- /dev/null
+++ b/src/lib/localstack/app-inspector.logic.test.ts
@@ -0,0 +1,153 @@
+import {
+ buildAppInspectorAnalyticsArgs,
+ buildQueryString,
+ formatAppInspectorApiError,
+ formatEventList,
+ formatSpanList,
+ formatTraceList,
+} from "../../tools/localstack-app-inspector";
+
+describe("localstack-app-inspector", () => {
+ describe("buildQueryString", () => {
+ it("encodes defined query parameters and skips undefined values", () => {
+ expect(
+ buildQueryString({
+ service_name: "lambda",
+ operation_name: "Send Message",
+ arn: "arn:aws:sqs:us-east-1:000000000000:queue/name",
+ region: undefined,
+ })
+ ).toBe(
+ "?service_name=lambda&operation_name=Send%20Message&arn=arn%3Aaws%3Asqs%3Aus-east-1%3A000000000000%3Aqueue%2Fname"
+ );
+ });
+ });
+
+ describe("buildAppInspectorAnalyticsArgs", () => {
+ it("keeps analytics privacy-safe by reporting shape instead of raw IDs", () => {
+ const analyticsArgs = buildAppInspectorAnalyticsArgs({
+ action: "get-event",
+ trace_id: "f".repeat(32),
+ span_id: "a".repeat(16),
+ event_id: "b".repeat(16),
+ account_id: "000000000000",
+ arn: "arn:aws:sqs:us-east-1:000000000000:queue/name",
+ limit: 25,
+ pagination_token: "cursor",
+ } as any);
+
+ expect(analyticsArgs).toEqual({
+ action: "get-event",
+ target_status: undefined,
+ has_trace_id: true,
+ has_span_id: true,
+ has_event_id: true,
+ trace_ids_count: undefined,
+ span_ids_count: undefined,
+ limit: 25,
+ has_pagination_token: true,
+ filter_keys: "account_id,arn,event_id,limit,pagination_token,span_id,trace_id",
+ });
+ expect(JSON.stringify(analyticsArgs)).not.toContain("000000000000");
+ expect(JSON.stringify(analyticsArgs)).not.toContain("arn:aws:sqs");
+ });
+
+ it("tracks delete request cardinality without raw IDs", () => {
+ const analyticsArgs = buildAppInspectorAnalyticsArgs({
+ action: "delete-spans",
+ trace_id: "f".repeat(32),
+ span_ids: ["a".repeat(16), "b".repeat(16)],
+ } as any);
+
+ expect(analyticsArgs.span_ids_count).toBe(2);
+ expect(analyticsArgs.has_trace_id).toBe(true);
+ expect(JSON.stringify(analyticsArgs)).not.toContain("aaaaaaaaaaaaaaaa");
+ });
+ });
+
+ describe("formatTraceList", () => {
+ it("formats trace summaries returned by the App Inspector API", () => {
+ const formatted = formatTraceList({
+ traces: [
+ {
+ trace_id: "f".repeat(32),
+ service_count: 3,
+ span_count: 5,
+ error_count: 1,
+ status_code: 2,
+ start_time_unix_nano: "1710000000000000000",
+ },
+ ],
+ pagination: {
+ total_count: 1,
+ has_next: false,
+ },
+ });
+
+ expect(formatted).toContain("## Traces (1)");
+ expect(formatted).toContain("ffffffffffffffffffffffffffffffff");
+ expect(formatted).toContain("Pagination");
+ });
+ });
+
+ describe("formatAppInspectorApiError", () => {
+ it("turns disabled App Inspector responses into actionable guidance", () => {
+ const formatted = formatAppInspectorApiError({
+ success: false,
+ statusCode: 503,
+ message: "AppInspector is not enabled",
+ });
+
+ expect(formatted.content[0].text).toContain("App Inspector Disabled");
+ expect(formatted.content[0].text).toContain("set-status");
+ expect(formatted.content[0].text).toContain("enabled");
+ });
+ });
+
+ describe("formatSpanList", () => {
+ it("formats span summaries with service and operation details", () => {
+ const formatted = formatSpanList(
+ {
+ spans: [
+ {
+ span_id: "a".repeat(16),
+ service_name: "lambda",
+ operation_name: "Invoke",
+ status_code: 1,
+ start_time_unix_nano: "1710000000000000000",
+ },
+ ],
+ pagination: { total_count: 1, has_next: false },
+ },
+ "f".repeat(32)
+ );
+
+ expect(formatted).toContain("## Spans for Trace");
+ expect(formatted).toContain("lambda");
+ expect(formatted).toContain("Invoke");
+ });
+ });
+
+ describe("formatEventList", () => {
+ it("formats event summaries including IAM policy events", () => {
+ const formatted = formatEventList(
+ {
+ events: [
+ {
+ event_id: "b".repeat(16),
+ name: "iam.policy.denied",
+ event_type: "iam.policy_evaluation",
+ timestamp_unix_nano: "1710000000000000000",
+ },
+ ],
+ },
+ "a".repeat(16),
+ "IAM Policy Evaluation "
+ );
+
+ expect(formatted).toContain("## IAM Policy Evaluation Events");
+ expect(formatted).toContain("iam.policy.denied");
+ expect(formatted).toContain("iam.policy_evaluation");
+ });
+ });
+});
diff --git a/src/lib/localstack/license-checker.ts b/src/lib/localstack/license-checker.ts
index fc69623..c732b84 100644
--- a/src/lib/localstack/license-checker.ts
+++ b/src/lib/localstack/license-checker.ts
@@ -4,6 +4,7 @@
*/
export enum ProFeature {
+ APP_INSPECTOR = "localstack.platform.plugin/appinspector",
IAM_ENFORCEMENT = "localstack.platform.plugin/iam-enforcement",
CLOUD_PODS = "localstack.platform.plugin/pods",
CHAOS_ENGINEERING = "localstack.platform.plugin/chaos",
diff --git a/src/lib/localstack/localstack.client.ts b/src/lib/localstack/localstack.client.ts
index 407f2da..830d5ae 100644
--- a/src/lib/localstack/localstack.client.ts
+++ b/src/lib/localstack/localstack.client.ts
@@ -45,6 +45,19 @@ export interface ReplicationSupportedResource {
[key: string]: unknown;
}
+export interface AppInspectorStatusResponse {
+ status: string;
+ note?: string;
+}
+
+export interface AppInspectorSetStatusResponse {
+ status: string;
+ changed: boolean;
+}
+
+export type AppInspectorStatus = "enabled" | "disabled";
+export type AppInspectorQuery = Record;
+
// Chaos API Client
export class ChaosApiClient {
private async makeRequest(
@@ -222,3 +235,107 @@ export class AwsReplicatorApiClient {
return this.makeRequest("/resources", "GET");
}
}
+
+// App Inspector API Client
+export class AppInspectorApiClient {
+ private buildQueryString(params: AppInspectorQuery): string {
+ const parts = Object.entries(params)
+ .filter(([, value]) => value !== undefined)
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
+ return parts.length ? `?${parts.join("&")}` : "";
+ }
+
+ private async makeRequest(
+ endpoint: string,
+ method: "GET" | "PUT" | "DELETE",
+ body?: unknown
+ ): Promise> {
+ try {
+ const data = await httpClient.request(`/_localstack/appinspector${endpoint}`, {
+ method,
+ headers: { "Content-Type": "application/json" },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ 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 App Inspector API: ${error instanceof Error ? error.message : "Unknown error"}`,
+ };
+ }
+ }
+
+ getStatus() {
+ return this.makeRequest("/status", "GET");
+ }
+
+ setStatus(status: AppInspectorStatus) {
+ return this.makeRequest("/status", "PUT", { status });
+ }
+
+ listTraces(query: AppInspectorQuery) {
+ return this.makeRequest(`/v1/traces${this.buildQueryString(query)}`, "GET");
+ }
+
+ getTrace(traceId: string) {
+ return this.makeRequest(`/v1/traces/${encodeURIComponent(traceId)}`, "GET");
+ }
+
+ deleteTraces(traceIds?: string[]) {
+ return this.makeRequest<{ deleted_count: number }>(
+ "/v1/traces",
+ "DELETE",
+ traceIds ? { trace_ids: traceIds } : {}
+ );
+ }
+
+ listSpans(traceId: string, query: AppInspectorQuery) {
+ return this.makeRequest(
+ `/v1/traces/${encodeURIComponent(traceId)}/spans${this.buildQueryString(query)}`,
+ "GET"
+ );
+ }
+
+ getSpan(traceId: string, spanId: string) {
+ return this.makeRequest(
+ `/v1/traces/${encodeURIComponent(traceId)}/spans/${encodeURIComponent(spanId)}`,
+ "GET"
+ );
+ }
+
+ deleteSpans(traceId: string, spanIds?: string[]) {
+ return this.makeRequest<{ deleted_count: number }>(
+ `/v1/traces/${encodeURIComponent(traceId)}/spans`,
+ "DELETE",
+ spanIds ? { span_ids: spanIds } : {}
+ );
+ }
+
+ listEvents(traceId: string, spanId: string, query: AppInspectorQuery) {
+ return this.makeRequest(
+ `/v1/traces/${encodeURIComponent(traceId)}/spans/${encodeURIComponent(spanId)}/events${this.buildQueryString(query)}`,
+ "GET"
+ );
+ }
+
+ getEvent(traceId: string, spanId: string, eventId: string) {
+ return this.makeRequest(
+ `/v1/traces/${encodeURIComponent(traceId)}/spans/${encodeURIComponent(spanId)}/events/${encodeURIComponent(eventId)}`,
+ "GET"
+ );
+ }
+
+ listIamEvents(traceId: string, spanId: string, query: AppInspectorQuery) {
+ return this.makeRequest(
+ `/v1/traces/${encodeURIComponent(traceId)}/spans/${encodeURIComponent(spanId)}/events/iam${this.buildQueryString(query)}`,
+ "GET"
+ );
+ }
+}
diff --git a/src/tools/localstack-app-inspector.ts b/src/tools/localstack-app-inspector.ts
new file mode 100644
index 0000000..59d6948
--- /dev/null
+++ b/src/tools/localstack-app-inspector.ts
@@ -0,0 +1,524 @@
+import { z } from "zod";
+import { type ToolMetadata, type InferSchema } from "xmcp";
+import {
+ runPreflights,
+ requireAuthToken,
+ requireLocalStackRunning,
+ requireProFeature,
+} from "../core/preflight";
+import { ResponseBuilder } from "../core/response-builder";
+import { withToolAnalytics } from "../core/analytics";
+import { ProFeature } from "../lib/localstack/license-checker";
+import {
+ AppInspectorApiClient,
+ type ApiResult,
+ type AppInspectorQuery,
+} from "../lib/localstack/localstack.client";
+
+export const schema = {
+ action: z
+ .enum([
+ "get-status",
+ "set-status",
+ "list-traces",
+ "get-trace",
+ "delete-traces",
+ "list-spans",
+ "get-span",
+ "delete-spans",
+ "list-events",
+ "get-event",
+ "list-iam-events",
+ ])
+ .describe(
+ "The App Inspector action to perform. Typical debugging flow: get-status, set-status to enabled if needed, run AWS workload, list-traces, list-spans for a trace, then list-events or list-iam-events for a span."
+ ),
+ status: z
+ .enum(["enabled", "disabled"])
+ .optional()
+ .describe(
+ "Status to set. Required for set-status. Use enabled before running a workload you want to inspect."
+ ),
+ trace_id: z
+ .string()
+ .trim()
+ .optional()
+ .describe(
+ "Trace ID. Required for get-trace, list-spans, get-span, delete-spans, list-events, get-event, and list-iam-events. For list-spans/list-events/list-iam-events, use '*' to query across all traces."
+ ),
+ span_id: z
+ .string()
+ .trim()
+ .optional()
+ .describe(
+ "Span ID. Required for get-span, list-events, get-event, and list-iam-events. For list-events/list-iam-events, use '*' to query across all spans."
+ ),
+ event_id: z.string().trim().optional().describe("Event ID. Required for get-event."),
+ trace_ids: z
+ .array(z.string())
+ .optional()
+ .describe("Trace IDs to delete for delete-traces. Omit to delete all traces."),
+ span_ids: z
+ .array(z.string())
+ .optional()
+ .describe("Span IDs to delete for delete-spans. Omit to delete all spans in the trace scope."),
+ limit: z
+ .number()
+ .int()
+ .min(1)
+ .max(1000)
+ .optional()
+ .describe("Maximum number of results to return (1-1000)"),
+ pagination_token: z.string().optional().describe("Pagination cursor from a previous response"),
+ service_name: z
+ .string()
+ .optional()
+ .describe("Filter traces or spans by AWS service name, e.g. lambda, s3, sqs."),
+ region: z.string().optional().describe("Filter by AWS region (e.g., 'us-east-1')"),
+ account_id: z
+ .string()
+ .optional()
+ .describe("Filter traces or spans by LocalStack AWS account ID."),
+ operation_name: z.string().optional().describe("Filter by operation name (e.g., 'CreateBucket')"),
+ resource_name: z.string().optional().describe("Filter by resource name"),
+ arn: z
+ .string()
+ .optional()
+ .describe("Filter traces or spans by resource ARN. This value is not sent to analytics."),
+ parent_span_id: z.string().optional().describe("Filter traces or spans by parent span ID."),
+ status_code: z
+ .number()
+ .int()
+ .min(0)
+ .optional()
+ .describe("Filter traces or spans by status code."),
+ start_time_unix_nano: z
+ .number()
+ .int()
+ .min(0)
+ .optional()
+ .describe("Filter traces or spans by start timestamp in Unix nanoseconds."),
+ end_time_unix_nano: z
+ .number()
+ .int()
+ .min(0)
+ .optional()
+ .describe("Filter traces or spans by end timestamp in Unix nanoseconds."),
+ version: z
+ .number()
+ .int()
+ .min(0)
+ .optional()
+ .describe("Filter traces or spans by App Inspector schema version."),
+ event_name: z.string().optional().describe("Filter events by event name."),
+ event_type: z
+ .string()
+ .optional()
+ .describe("Filter events by event type, e.g. iam.policy_evaluation."),
+};
+
+export type AppInspectorArgs = InferSchema;
+
+export const metadata: ToolMetadata = {
+ name: "localstack-app-inspector",
+ description:
+ "Query and manage App Inspector traces, spans, and events to review deployed LocalStack applications",
+ annotations: {
+ title: "LocalStack App Inspector",
+ readOnlyHint: false,
+ destructiveHint: true,
+ idempotentHint: false,
+ },
+};
+
+export default async function localstackAppInspector(params: AppInspectorArgs) {
+ const { action } = params;
+ return withToolAnalytics(
+ "localstack-app-inspector",
+ buildAppInspectorAnalyticsArgs(params),
+ async () => {
+ const preflightError = await runPreflights([
+ requireAuthToken(),
+ requireLocalStackRunning(),
+ requireProFeature(ProFeature.APP_INSPECTOR),
+ ]);
+ if (preflightError) return preflightError;
+
+ const client = new AppInspectorApiClient();
+
+ switch (action) {
+ case "get-status":
+ return await handleGetStatus(client);
+ case "set-status":
+ return await handleSetStatus(client, params);
+ case "list-traces":
+ return await handleListTraces(client, params);
+ case "get-trace":
+ return await handleGetTrace(client, params);
+ case "delete-traces":
+ return await handleDeleteTraces(client, params);
+ case "list-spans":
+ return await handleListSpans(client, params);
+ case "get-span":
+ return await handleGetSpan(client, params);
+ case "delete-spans":
+ return await handleDeleteSpans(client, params);
+ case "list-events":
+ return await handleListEvents(client, params);
+ case "get-event":
+ return await handleGetEvent(client, params);
+ case "list-iam-events":
+ return await handleListIamEvents(client, params);
+ default:
+ return ResponseBuilder.error("Unknown action", `Unknown action: ${action}`);
+ }
+ }
+ );
+}
+
+// ─── Status ──────────────────────────────────────────────────────────────────
+
+async function handleGetStatus(client: AppInspectorApiClient) {
+ const result = await client.getStatus();
+ if (!result.success) return formatAppInspectorApiError(result);
+
+ let response = `## App Inspector Status\n\n**Status:** ${result.data.status}`;
+ if (result.data.note) response += `\n\n${result.data.note}`;
+ return ResponseBuilder.markdown(response);
+}
+
+async function handleSetStatus(client: AppInspectorApiClient, params: AppInspectorArgs) {
+ if (!params.status) {
+ return ResponseBuilder.error(
+ "Missing Parameter",
+ '`status` is required for set-status ("enabled" or "disabled")'
+ );
+ }
+ const result = await client.setStatus(params.status);
+ if (!result.success) return formatAppInspectorApiError(result);
+
+ const changed = result.data.changed ? "Status changed." : "Status was already set.";
+ return ResponseBuilder.markdown(
+ `## App Inspector Status Updated\n\n**Status:** ${result.data.status}\n\n${changed}`
+ );
+}
+
+// ─── Traces ──────────────────────────────────────────────────────────────────
+
+async function handleListTraces(client: AppInspectorApiClient, params: AppInspectorArgs) {
+ const result = await client.listTraces(buildTraceFilters(params));
+ if (!result.success) return formatAppInspectorApiError(result);
+ return ResponseBuilder.markdown(formatTraceList(result.data));
+}
+
+async function handleGetTrace(client: AppInspectorApiClient, params: AppInspectorArgs) {
+ if (!params.trace_id) {
+ return ResponseBuilder.error("Missing Parameter", "`trace_id` is required for get-trace");
+ }
+ const result = await client.getTrace(params.trace_id);
+ if (!result.success) return formatAppInspectorApiError(result);
+
+ return ResponseBuilder.markdown(
+ `## Trace: ${params.trace_id}\n\n\`\`\`json\n${JSON.stringify(result.data, null, 2)}\n\`\`\``
+ );
+}
+
+async function handleDeleteTraces(client: AppInspectorApiClient, params: AppInspectorArgs) {
+ const result = await client.deleteTraces(params.trace_ids);
+ if (!result.success) return formatAppInspectorApiError(result);
+
+ const scope = params.trace_ids ? `${params.trace_ids.length} trace(s)` : "all traces";
+ return ResponseBuilder.markdown(
+ `## Traces Deleted\n\nDeleted ${result.data.deleted_count} trace(s) (requested: ${scope}).`
+ );
+}
+
+// ─── Spans ───────────────────────────────────────────────────────────────────
+
+async function handleListSpans(client: AppInspectorApiClient, params: AppInspectorArgs) {
+ if (!params.trace_id) {
+ return ResponseBuilder.error("Missing Parameter", "`trace_id` is required for list-spans");
+ }
+ const result = await client.listSpans(params.trace_id, buildSpanFilters(params));
+ if (!result.success) return formatAppInspectorApiError(result);
+ return ResponseBuilder.markdown(formatSpanList(result.data, params.trace_id));
+}
+
+async function handleGetSpan(client: AppInspectorApiClient, params: AppInspectorArgs) {
+ if (!params.trace_id || !params.span_id) {
+ return ResponseBuilder.error(
+ "Missing Parameter",
+ "`trace_id` and `span_id` are required for get-span"
+ );
+ }
+ const result = await client.getSpan(params.trace_id, params.span_id);
+ if (!result.success) return formatAppInspectorApiError(result);
+
+ return ResponseBuilder.markdown(
+ `## Span: ${params.span_id}\n\n\`\`\`json\n${JSON.stringify(result.data, null, 2)}\n\`\`\``
+ );
+}
+
+async function handleDeleteSpans(client: AppInspectorApiClient, params: AppInspectorArgs) {
+ if (!params.trace_id) {
+ return ResponseBuilder.error("Missing Parameter", "`trace_id` is required for delete-spans");
+ }
+ const result = await client.deleteSpans(params.trace_id, params.span_ids);
+ if (!result.success) return formatAppInspectorApiError(result);
+
+ const scope = params.span_ids ? `${params.span_ids.length} span(s)` : "all spans in trace";
+ return ResponseBuilder.markdown(
+ `## Spans Deleted\n\nDeleted ${result.data.deleted_count} span(s) (requested: ${scope}).`
+ );
+}
+
+// ─── Events ──────────────────────────────────────────────────────────────────
+
+async function handleListEvents(client: AppInspectorApiClient, params: AppInspectorArgs) {
+ if (!params.trace_id || !params.span_id) {
+ return ResponseBuilder.error(
+ "Missing Parameter",
+ "`trace_id` and `span_id` are required for list-events"
+ );
+ }
+ const result = await client.listEvents(
+ params.trace_id,
+ params.span_id,
+ buildEventFilters(params)
+ );
+ if (!result.success) return formatAppInspectorApiError(result);
+ return ResponseBuilder.markdown(formatEventList(result.data, params.span_id));
+}
+
+async function handleGetEvent(client: AppInspectorApiClient, params: AppInspectorArgs) {
+ if (!params.trace_id || !params.span_id || !params.event_id) {
+ return ResponseBuilder.error(
+ "Missing Parameter",
+ "`trace_id`, `span_id`, and `event_id` are required for get-event"
+ );
+ }
+ const result = await client.getEvent(params.trace_id, params.span_id, params.event_id);
+ if (!result.success) return formatAppInspectorApiError(result);
+
+ return ResponseBuilder.markdown(
+ `## Event: ${params.event_id}\n\n\`\`\`json\n${JSON.stringify(result.data, null, 2)}\n\`\`\``
+ );
+}
+
+async function handleListIamEvents(client: AppInspectorApiClient, params: AppInspectorArgs) {
+ if (!params.trace_id || !params.span_id) {
+ return ResponseBuilder.error(
+ "Missing Parameter",
+ "`trace_id` and `span_id` are required for list-iam-events"
+ );
+ }
+ const result = await client.listIamEvents(
+ params.trace_id,
+ params.span_id,
+ buildEventFilters(params)
+ );
+ if (!result.success) return formatAppInspectorApiError(result);
+ return ResponseBuilder.markdown(
+ formatEventList(result.data, params.span_id, "IAM Policy Evaluation ")
+ );
+}
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+export function buildAppInspectorAnalyticsArgs(params: AppInspectorArgs) {
+ const filterKeys = Object.entries(buildFilters(params))
+ .filter(([, value]) => value !== undefined)
+ .map(([key]) => key)
+ .sort();
+ return {
+ action: params.action,
+ target_status: params.action === "set-status" ? params.status : undefined,
+ has_trace_id: Boolean(params.trace_id),
+ has_span_id: Boolean(params.span_id),
+ has_event_id: Boolean(params.event_id),
+ trace_ids_count: params.trace_ids?.length,
+ span_ids_count: params.span_ids?.length,
+ limit: params.limit,
+ has_pagination_token: Boolean(params.pagination_token),
+ filter_keys: filterKeys.length ? filterKeys.join(",") : undefined,
+ };
+}
+
+export function formatAppInspectorApiError(result: ApiResult) {
+ if (result.success) return ResponseBuilder.error("Unexpected App Inspector Result");
+
+ if (result.statusCode === 403 || result.statusCode === 503) {
+ return ResponseBuilder.error(
+ "App Inspector Disabled",
+ 'App Inspector is not enabled. Use the `set-status` action with `status: "enabled"` to turn it on.'
+ );
+ }
+ return ResponseBuilder.error(
+ result.statusCode ? `HTTP ${result.statusCode}` : "App Inspector API Error",
+ result.message
+ );
+}
+
+function buildFilters(params: AppInspectorArgs): AppInspectorQuery {
+ return {
+ limit: params.limit,
+ pagination_token: params.pagination_token,
+ trace_id: params.trace_id,
+ span_id: params.span_id,
+ event_id: params.event_id,
+ service_name: params.service_name,
+ region: params.region,
+ account_id: params.account_id,
+ operation_name: params.operation_name,
+ resource_name: params.resource_name,
+ arn: params.arn,
+ parent_span_id: params.parent_span_id,
+ status_code: params.status_code,
+ start_time_unix_nano: params.start_time_unix_nano,
+ end_time_unix_nano: params.end_time_unix_nano,
+ version: params.version,
+ name: params.event_name,
+ event_type: params.event_type,
+ };
+}
+
+function buildTraceFilters(params: AppInspectorArgs): AppInspectorQuery {
+ return {
+ limit: params.limit,
+ pagination_token: params.pagination_token,
+ trace_id: params.trace_id,
+ parent_span_id: params.parent_span_id,
+ service_name: params.service_name,
+ region: params.region,
+ account_id: params.account_id,
+ operation_name: params.operation_name,
+ resource_name: params.resource_name,
+ arn: params.arn,
+ status_code: params.status_code,
+ start_time_unix_nano: params.start_time_unix_nano,
+ end_time_unix_nano: params.end_time_unix_nano,
+ version: params.version,
+ };
+}
+
+function buildSpanFilters(params: AppInspectorArgs): AppInspectorQuery {
+ return {
+ limit: params.limit,
+ pagination_token: params.pagination_token,
+ span_id: params.span_id,
+ parent_span_id: params.parent_span_id,
+ service_name: params.service_name,
+ region: params.region,
+ account_id: params.account_id,
+ operation_name: params.operation_name,
+ resource_name: params.resource_name,
+ arn: params.arn,
+ status_code: params.status_code,
+ start_time_unix_nano: params.start_time_unix_nano,
+ end_time_unix_nano: params.end_time_unix_nano,
+ version: params.version,
+ };
+}
+
+function buildEventFilters(params: AppInspectorArgs): AppInspectorQuery {
+ return {
+ limit: params.limit,
+ pagination_token: params.pagination_token,
+ timestamp_unix_nano: params.start_time_unix_nano,
+ name: params.event_name,
+ event_type: params.event_type,
+ };
+}
+
+export function buildQueryString(params: AppInspectorQuery): string {
+ const parts = Object.entries(params)
+ .filter(([, v]) => v !== undefined)
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
+ return parts.length ? `?${parts.join("&")}` : "";
+}
+
+export function formatTraceList(data: any): string {
+ const traces: any[] = data?.traces ?? data ?? [];
+ if (!Array.isArray(traces) || traces.length === 0) {
+ return "## Traces\n\nNo traces found.";
+ }
+ const rows = traces
+ .map((t: any) => {
+ const id = t.trace_id ?? t.id ?? "-";
+ const services = t.service_count ?? "-";
+ const spans = t.span_count ?? "-";
+ const errors = t.error_count ?? "-";
+ const status = t.status_code ?? "-";
+ const start = t.start_time_unix_nano ? formatNanoTime(t.start_time_unix_nano) : "-";
+ return `| ${id} | ${services} | ${spans} | ${errors} | ${status} | ${start} |`;
+ })
+ .join("\n");
+
+ let result = `## Traces (${traces.length})\n\n`;
+ result += `| Trace ID | Services | Spans | Errors | Status | Start Time |\n`;
+ result += `|---|---|---|---|---|---|\n`;
+ result += rows;
+
+ if (data?.pagination) {
+ const p = data.pagination;
+ result += `\n\n**Pagination:** Total: ${p.total_count ?? "?"} | Has next: ${p.has_next}`;
+ if (p.next_cursor) result += ` | Next cursor: \`${p.next_cursor}\``;
+ }
+ return result;
+}
+
+export function formatSpanList(data: any, traceId: string): string {
+ const spans: any[] = data?.spans ?? data ?? [];
+ if (!Array.isArray(spans) || spans.length === 0) {
+ return `## Spans for Trace \`${traceId}\`\n\nNo spans found.`;
+ }
+ const rows = spans
+ .map((s: any) => {
+ const id = s.span_id ?? s.id ?? "-";
+ const service = s.service_name ?? "-";
+ const operation = s.operation_name ?? "-";
+ const status = s.status_code ?? "-";
+ const start = s.start_time_unix_nano ? formatNanoTime(s.start_time_unix_nano) : "-";
+ return `| ${id} | ${service} | ${operation} | ${status} | ${start} |`;
+ })
+ .join("\n");
+
+ let result = `## Spans for Trace \`${traceId}\` (${spans.length})\n\n`;
+ result += `| Span ID | Service | Operation | Status | Start Time |\n`;
+ result += `|---|---|---|---|---|\n`;
+ result += rows;
+
+ if (data?.pagination) {
+ const p = data.pagination;
+ result += `\n\n**Pagination:** Total: ${p.total_count ?? "?"} | Has next: ${p.has_next}`;
+ if (p.next_cursor) result += ` | Next cursor: \`${p.next_cursor}\``;
+ }
+ return result;
+}
+
+export function formatEventList(data: any, spanId: string, prefix = ""): string {
+ const events: any[] = data?.events ?? data ?? [];
+ if (!Array.isArray(events) || events.length === 0) {
+ return `## ${prefix}Events for Span \`${spanId}\`\n\nNo events found.`;
+ }
+ const rows = events
+ .map((e: any) => {
+ const id = e.event_id ?? e.id ?? "-";
+ const name = e.name ?? "-";
+ const type = e.event_type ?? "-";
+ const ts = e.timestamp_unix_nano ? formatNanoTime(e.timestamp_unix_nano) : "-";
+ return `| ${id} | ${name} | ${type} | ${ts} |`;
+ })
+ .join("\n");
+
+ let result = `## ${prefix}Events for Span \`${spanId}\` (${events.length})\n\n`;
+ result += `| Event ID | Name | Type | Timestamp |\n`;
+ result += `|---|---|---|---|\n`;
+ result += rows;
+ return result;
+}
+
+function formatNanoTime(nanos: number | string): string {
+ const ms = Number(nanos) / 1_000_000;
+ return new Date(ms).toISOString();
+}
diff --git a/tests/mcp/direct.spec.mjs b/tests/mcp/direct.spec.mjs
index 11cac41..57bb5ca 100644
--- a/tests/mcp/direct.spec.mjs
+++ b/tests/mcp/direct.spec.mjs
@@ -13,6 +13,7 @@ const EXPECTED_TOOLS = [
"localstack-aws-client",
"localstack-aws-replicator",
"localstack-docs",
+ "localstack-app-inspector",
];
function requireEnv(name) {