From 011d924d3c8e0aef40dc3c3fedc16f81a06f8b32 Mon Sep 17 00:00:00 2001 From: Shelton Graves Date: Wed, 29 Apr 2026 09:50:13 -0400 Subject: [PATCH 1/4] add localstack-app-inspector tool Adds a new MCP tool for querying and managing App Inspector traces, spans, and events. Supports get/set status, list/get/delete traces, list/get/delete spans, and list events including IAM policy evaluations. Co-Authored-By: Claude Sonnet 4.6 --- manifest.json | 4 + src/tools/localstack-app-inspector.ts | 397 ++++++++++++++++++++++++++ 2 files changed, 401 insertions(+) create mode 100644 src/tools/localstack-app-inspector.ts diff --git a/manifest.json b/manifest.json index cc2e598..59ded0a 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": "Query and manage App Inspector traces, spans, and events to review deployed LocalStack applications" } ], "prompts": [ diff --git a/src/tools/localstack-app-inspector.ts b/src/tools/localstack-app-inspector.ts new file mode 100644 index 0000000..b2cb92c --- /dev/null +++ b/src/tools/localstack-app-inspector.ts @@ -0,0 +1,397 @@ +import { z } from "zod"; +import { type ToolMetadata, type InferSchema } from "xmcp"; +import { httpClient, HttpError } from "../core/http-client"; +import { runPreflights, requireAuthToken, requireLocalStackRunning } from "../core/preflight"; +import { ResponseBuilder } from "../core/response-builder"; +import { withToolAnalytics } from "../core/analytics"; + +const API_PREFIX = "/_localstack/appinspector"; +const API_V1 = `${API_PREFIX}/v1`; + +export const schema = { + action: z + .enum([ + "get-status", + "set-status", + "list-traces", + "get-trace", + "delete-traces", + "list-spans", + "get-span", + "delete-spans", + "list-events", + "list-iam-events", + ]) + .describe("The App Inspector action to perform"), + status: z + .enum(["enabled", "disabled"]) + .optional() + .describe("Status to set (required for set-status)"), + trace_id: z + .string() + .optional() + .describe("Trace ID (required for get-trace, list-spans, get-span, delete-spans, list-events, list-iam-events)"), + span_id: z + .string() + .optional() + .describe("Span ID (required for get-span, list-events, list-iam-events)"), + 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)"), + 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 by service name (e.g., 'lambda', 's3')"), + region: z + .string() + .optional() + .describe("Filter by AWS region (e.g., 'us-east-1')"), + account_id: z + .string() + .optional() + .describe("Filter by 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 by resource ARN"), +}; + +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: false, + idempotentHint: false, + }, +}; + +export default async function localstackAppInspector( + params: InferSchema +) { + const { action } = params; + return withToolAnalytics("localstack-app-inspector", { action }, async () => { + const preflightError = await runPreflights([ + requireAuthToken(), + requireLocalStackRunning(), + ]); + if (preflightError) return preflightError; + + try { + switch (action) { + case "get-status": + return await handleGetStatus(); + case "set-status": + return await handleSetStatus(params); + case "list-traces": + return await handleListTraces(params); + case "get-trace": + return await handleGetTrace(params); + case "delete-traces": + return await handleDeleteTraces(params); + case "list-spans": + return await handleListSpans(params); + case "get-span": + return await handleGetSpan(params); + case "delete-spans": + return await handleDeleteSpans(params); + case "list-events": + return await handleListEvents(params); + case "list-iam-events": + return await handleListIamEvents(params); + default: + return ResponseBuilder.error("Unknown action", `Unknown action: ${action}`); + } + } catch (err) { + if (err instanceof HttpError) { + if (err.status === 403) { + 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( + `HTTP ${err.status}`, + err.body || err.message + ); + } + throw err; + } + }); +} + +// ─── Status ────────────────────────────────────────────────────────────────── + +async function handleGetStatus() { + const data = await httpClient.request<{ status: string; note?: string }>( + `${API_PREFIX}/status` + ); + let result = `## App Inspector Status\n\n**Status:** ${data.status}`; + if (data.note) result += `\n\n${data.note}`; + return ResponseBuilder.markdown(result); +} + +async function handleSetStatus(params: InferSchema) { + if (!params.status) { + return ResponseBuilder.error( + "Missing Parameter", + "`status` is required for set-status (\"enabled\" or \"disabled\")" + ); + } + const data = await httpClient.request<{ status: string; changed: boolean }>( + `${API_PREFIX}/status`, + { + method: "PUT", + body: JSON.stringify({ status: params.status }), + headers: { "Content-Type": "application/json" }, + } + ); + const changed = data.changed ? "Status changed." : "Status was already set."; + return ResponseBuilder.markdown( + `## App Inspector Status Updated\n\n**Status:** ${data.status}\n\n${changed}` + ); +} + +// ─── Traces ────────────────────────────────────────────────────────────────── + +async function handleListTraces(params: InferSchema) { + const qs = buildQueryString(buildFilters(params)); + const data = await httpClient.request(`${API_V1}/traces${qs}`); + return ResponseBuilder.markdown(formatTraceList(data)); +} + +async function handleGetTrace(params: InferSchema) { + if (!params.trace_id) { + return ResponseBuilder.error("Missing Parameter", "`trace_id` is required for get-trace"); + } + const data = await httpClient.request(`${API_V1}/traces/${params.trace_id}`); + return ResponseBuilder.markdown( + `## Trace: ${params.trace_id}\n\n\`\`\`json\n${JSON.stringify(data, null, 2)}\n\`\`\`` + ); +} + +async function handleDeleteTraces(params: InferSchema) { + const body = params.trace_ids ? { trace_ids: params.trace_ids } : {}; + const data = await httpClient.request<{ deleted_count: number }>( + `${API_V1}/traces`, + { + method: "DELETE", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + } + ); + const scope = params.trace_ids ? `${params.trace_ids.length} trace(s)` : "all traces"; + return ResponseBuilder.markdown( + `## Traces Deleted\n\nDeleted ${data.deleted_count} trace(s) (requested: ${scope}).` + ); +} + +// ─── Spans ─────────────────────────────────────────────────────────────────── + +async function handleListSpans(params: InferSchema) { + if (!params.trace_id) { + return ResponseBuilder.error("Missing Parameter", "`trace_id` is required for list-spans"); + } + const qs = buildQueryString(buildFilters(params)); + const data = await httpClient.request( + `${API_V1}/traces/${params.trace_id}/spans${qs}` + ); + return ResponseBuilder.markdown(formatSpanList(data, params.trace_id)); +} + +async function handleGetSpan(params: InferSchema) { + if (!params.trace_id || !params.span_id) { + return ResponseBuilder.error( + "Missing Parameter", + "`trace_id` and `span_id` are required for get-span" + ); + } + const data = await httpClient.request( + `${API_V1}/traces/${params.trace_id}/spans/${params.span_id}` + ); + return ResponseBuilder.markdown( + `## Span: ${params.span_id}\n\n\`\`\`json\n${JSON.stringify(data, null, 2)}\n\`\`\`` + ); +} + +async function handleDeleteSpans(params: InferSchema) { + if (!params.trace_id) { + return ResponseBuilder.error("Missing Parameter", "`trace_id` is required for delete-spans"); + } + const body = params.span_ids ? { span_ids: params.span_ids } : {}; + const data = await httpClient.request<{ deleted_count: number }>( + `${API_V1}/traces/${params.trace_id}/spans`, + { + method: "DELETE", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + } + ); + const scope = params.span_ids ? `${params.span_ids.length} span(s)` : "all spans in trace"; + return ResponseBuilder.markdown( + `## Spans Deleted\n\nDeleted ${data.deleted_count} span(s) (requested: ${scope}).` + ); +} + +// ─── Events ────────────────────────────────────────────────────────────────── + +async function handleListEvents(params: InferSchema) { + if (!params.trace_id || !params.span_id) { + return ResponseBuilder.error( + "Missing Parameter", + "`trace_id` and `span_id` are required for list-events" + ); + } + const qs = buildQueryString(buildFilters(params)); + const data = await httpClient.request( + `${API_V1}/traces/${params.trace_id}/spans/${params.span_id}/events${qs}` + ); + return ResponseBuilder.markdown(formatEventList(data, params.span_id)); +} + +async function handleListIamEvents(params: InferSchema) { + if (!params.trace_id || !params.span_id) { + return ResponseBuilder.error( + "Missing Parameter", + "`trace_id` and `span_id` are required for list-iam-events" + ); + } + const qs = buildQueryString(buildFilters(params)); + const data = await httpClient.request( + `${API_V1}/traces/${params.trace_id}/spans/${params.span_id}/events/iam${qs}` + ); + return ResponseBuilder.markdown(formatEventList(data, params.span_id, "IAM Policy Evaluation ")); +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function buildFilters(params: InferSchema): Record { + return { + limit: params.limit, + pagination_token: params.pagination_token, + 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, + }; +} + +function buildQueryString(params: Record): string { + const parts = Object.entries(params) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`); + return parts.length ? `?${parts.join("&")}` : ""; +} + +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 service = t.service_name ?? "-"; + const operation = t.operation_name ?? "-"; + const status = t.status_code ?? "-"; + const start = t.start_time_unix_nano ? formatNanoTime(t.start_time_unix_nano) : "-"; + return `| ${id} | ${service} | ${operation} | ${status} | ${start} |`; + }) + .join("\n"); + + let result = `## Traces (${traces.length})\n\n`; + result += `| Trace 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; +} + +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; +} + +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(); +} From 5e161061245b760af37e87c548a63d2370d15445 Mon Sep 17 00:00:00 2001 From: Harsh Mishra Date: Wed, 29 Apr 2026 22:38:24 +0530 Subject: [PATCH 2/4] add docs and direct tests --- README.md | 52 +++++++++++++++++++++------------------ tests/mcp/direct.spec.mjs | 1 + 2 files changed, 29 insertions(+), 24 deletions(-) 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** | [![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=localstack-mcp-server&config=eyJjb21tYW5kIjoibnB4IC15IEBsb2NhbHN0YWNrL2xvY2Fsc3RhY2stbWNwLXNlcnZlciJ9) | +| Editor | Installation | +| :--------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Cursor** | [![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](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/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) { From 719827e0bb1f4a695d8066b29bb15eed8dca1d7e Mon Sep 17 00:00:00 2001 From: Harsh Mishra Date: Wed, 29 Apr 2026 22:55:27 +0530 Subject: [PATCH 3/4] revamp the inspector tool, add license check, unit tests, analytics and configs --- manifest.json | 19 +- server.json | 7 + src/core/analytics.ts | 12 + .../localstack/app-inspector.logic.test.ts | 152 ++++++++ src/lib/localstack/license-checker.ts | 1 + src/tools/localstack-app-inspector.ts | 340 ++++++++++++------ 6 files changed, 428 insertions(+), 103 deletions(-) create mode 100644 src/lib/localstack/app-inspector.logic.test.ts diff --git a/manifest.json b/manifest.json index 59ded0a..192d2b5 100644 --- a/manifest.json +++ b/manifest.json @@ -73,7 +73,7 @@ }, { "name": "localstack-app-inspector", - "description": "Query and manage App Inspector traces, spans, and events to review deployed LocalStack applications" + "description": "Inspect LocalStack application traces, spans, events, payload metadata, and IAM policy evaluations" } ], "prompts": [ @@ -157,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..f7fe0ff --- /dev/null +++ b/src/lib/localstack/app-inspector.logic.test.ts @@ -0,0 +1,152 @@ +import { + buildAppInspectorAnalyticsArgs, + buildQueryString, + formatAppInspectorHttpError, + formatEventList, + formatSpanList, + formatTraceList, +} from "../../tools/localstack-app-inspector"; +import { HttpError } from "../../core/http-client"; + +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("formatAppInspectorHttpError", () => { + it("turns disabled App Inspector responses into actionable guidance", () => { + const formatted = formatAppInspectorHttpError( + new HttpError(503, "Service Unavailable", "AppInspector is not enabled", "disabled") + ); + + 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/tools/localstack-app-inspector.ts b/src/tools/localstack-app-inspector.ts index b2cb92c..4613600 100644 --- a/src/tools/localstack-app-inspector.ts +++ b/src/tools/localstack-app-inspector.ts @@ -1,9 +1,15 @@ import { z } from "zod"; import { type ToolMetadata, type InferSchema } from "xmcp"; import { httpClient, HttpError } from "../core/http-client"; -import { runPreflights, requireAuthToken, requireLocalStackRunning } from "../core/preflight"; +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"; const API_PREFIX = "/_localstack/appinspector"; const API_V1 = `${API_PREFIX}/v1`; @@ -20,29 +26,41 @@ export const schema = { "get-span", "delete-spans", "list-events", + "get-event", "list-iam-events", ]) - .describe("The App Inspector action to perform"), + .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)"), + .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, list-iam-events)"), + .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, list-iam-events)"), + .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)"), + .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)"), + .describe("Span IDs to delete for delete-spans. Omit to delete all spans in the trace scope."), limit: z .number() .int() @@ -50,34 +68,52 @@ export const schema = { .max(1000) .optional() .describe("Maximum number of results to return (1-1000)"), - pagination_token: z - .string() - .optional() - .describe("Pagination cursor from a previous response"), + pagination_token: z.string().optional().describe("Pagination cursor from a previous response"), service_name: z .string() .optional() - .describe("Filter by service name (e.g., 'lambda', 's3')"), - region: z - .string() - .optional() - .describe("Filter by AWS region (e.g., 'us-east-1')"), + .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 by AWS account ID"), - operation_name: z + .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 by operation name (e.g., 'CreateBucket')"), - resource_name: z - .string() + .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 by resource name"), - arn: z + .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 by resource ARN"), + .describe("Filter events by event type, e.g. iam.policy_evaluation."), }; export const metadata: ToolMetadata = { @@ -87,71 +123,65 @@ export const metadata: ToolMetadata = { annotations: { title: "LocalStack App Inspector", readOnlyHint: false, - destructiveHint: false, + destructiveHint: true, idempotentHint: false, }, }; -export default async function localstackAppInspector( - params: InferSchema -) { +export default async function localstackAppInspector(params: InferSchema) { const { action } = params; - return withToolAnalytics("localstack-app-inspector", { action }, async () => { - const preflightError = await runPreflights([ - requireAuthToken(), - requireLocalStackRunning(), - ]); - if (preflightError) return preflightError; - - try { - switch (action) { - case "get-status": - return await handleGetStatus(); - case "set-status": - return await handleSetStatus(params); - case "list-traces": - return await handleListTraces(params); - case "get-trace": - return await handleGetTrace(params); - case "delete-traces": - return await handleDeleteTraces(params); - case "list-spans": - return await handleListSpans(params); - case "get-span": - return await handleGetSpan(params); - case "delete-spans": - return await handleDeleteSpans(params); - case "list-events": - return await handleListEvents(params); - case "list-iam-events": - return await handleListIamEvents(params); - default: - return ResponseBuilder.error("Unknown action", `Unknown action: ${action}`); - } - } catch (err) { - if (err instanceof HttpError) { - if (err.status === 403) { - return ResponseBuilder.error( - "App Inspector Disabled", - "App Inspector is not enabled. Use the `set-status` action with `status: \"enabled\"` to turn it on." - ); + return withToolAnalytics( + "localstack-app-inspector", + buildAppInspectorAnalyticsArgs(params), + async () => { + const preflightError = await runPreflights([ + requireAuthToken(), + requireLocalStackRunning(), + requireProFeature(ProFeature.APP_INSPECTOR), + ]); + if (preflightError) return preflightError; + + try { + switch (action) { + case "get-status": + return await handleGetStatus(); + case "set-status": + return await handleSetStatus(params); + case "list-traces": + return await handleListTraces(params); + case "get-trace": + return await handleGetTrace(params); + case "delete-traces": + return await handleDeleteTraces(params); + case "list-spans": + return await handleListSpans(params); + case "get-span": + return await handleGetSpan(params); + case "delete-spans": + return await handleDeleteSpans(params); + case "list-events": + return await handleListEvents(params); + case "get-event": + return await handleGetEvent(params); + case "list-iam-events": + return await handleListIamEvents(params); + default: + return ResponseBuilder.error("Unknown action", `Unknown action: ${action}`); } - return ResponseBuilder.error( - `HTTP ${err.status}`, - err.body || err.message - ); + } catch (err) { + if (err instanceof HttpError) { + return formatAppInspectorHttpError(err); + } + throw err; } - throw err; } - }); + ); } // ─── Status ────────────────────────────────────────────────────────────────── async function handleGetStatus() { - const data = await httpClient.request<{ status: string; note?: string }>( - `${API_PREFIX}/status` - ); + const data = await httpClient.request<{ status: string; note?: string }>(`${API_PREFIX}/status`); let result = `## App Inspector Status\n\n**Status:** ${data.status}`; if (data.note) result += `\n\n${data.note}`; return ResponseBuilder.markdown(result); @@ -161,7 +191,7 @@ async function handleSetStatus(params: InferSchema) { if (!params.status) { return ResponseBuilder.error( "Missing Parameter", - "`status` is required for set-status (\"enabled\" or \"disabled\")" + '`status` is required for set-status ("enabled" or "disabled")' ); } const data = await httpClient.request<{ status: string; changed: boolean }>( @@ -181,7 +211,7 @@ async function handleSetStatus(params: InferSchema) { // ─── Traces ────────────────────────────────────────────────────────────────── async function handleListTraces(params: InferSchema) { - const qs = buildQueryString(buildFilters(params)); + const qs = buildQueryString(buildTraceFilters(params)); const data = await httpClient.request(`${API_V1}/traces${qs}`); return ResponseBuilder.markdown(formatTraceList(data)); } @@ -198,14 +228,11 @@ async function handleGetTrace(params: InferSchema) { async function handleDeleteTraces(params: InferSchema) { const body = params.trace_ids ? { trace_ids: params.trace_ids } : {}; - const data = await httpClient.request<{ deleted_count: number }>( - `${API_V1}/traces`, - { - method: "DELETE", - body: JSON.stringify(body), - headers: { "Content-Type": "application/json" }, - } - ); + const data = await httpClient.request<{ deleted_count: number }>(`${API_V1}/traces`, { + method: "DELETE", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); const scope = params.trace_ids ? `${params.trace_ids.length} trace(s)` : "all traces"; return ResponseBuilder.markdown( `## Traces Deleted\n\nDeleted ${data.deleted_count} trace(s) (requested: ${scope}).` @@ -218,10 +245,8 @@ async function handleListSpans(params: InferSchema) { if (!params.trace_id) { return ResponseBuilder.error("Missing Parameter", "`trace_id` is required for list-spans"); } - const qs = buildQueryString(buildFilters(params)); - const data = await httpClient.request( - `${API_V1}/traces/${params.trace_id}/spans${qs}` - ); + const qs = buildQueryString(buildSpanFilters(params)); + const data = await httpClient.request(`${API_V1}/traces/${params.trace_id}/spans${qs}`); return ResponseBuilder.markdown(formatSpanList(data, params.trace_id)); } @@ -268,13 +293,28 @@ async function handleListEvents(params: InferSchema) { "`trace_id` and `span_id` are required for list-events" ); } - const qs = buildQueryString(buildFilters(params)); + const qs = buildQueryString(buildEventFilters(params)); const data = await httpClient.request( `${API_V1}/traces/${params.trace_id}/spans/${params.span_id}/events${qs}` ); return ResponseBuilder.markdown(formatEventList(data, params.span_id)); } +async function handleGetEvent(params: InferSchema) { + 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 data = await httpClient.request( + `${API_V1}/traces/${params.trace_id}/spans/${params.span_id}/events/${params.event_id}` + ); + return ResponseBuilder.markdown( + `## Event: ${params.event_id}\n\n\`\`\`json\n${JSON.stringify(data, null, 2)}\n\`\`\`` + ); +} + async function handleListIamEvents(params: InferSchema) { if (!params.trace_id || !params.span_id) { return ResponseBuilder.error( @@ -282,7 +322,7 @@ async function handleListIamEvents(params: InferSchema) { "`trace_id` and `span_id` are required for list-iam-events" ); } - const qs = buildQueryString(buildFilters(params)); + const qs = buildQueryString(buildEventFilters(params)); const data = await httpClient.request( `${API_V1}/traces/${params.trace_id}/spans/${params.span_id}/events/iam${qs}` ); @@ -291,27 +331,122 @@ async function handleListIamEvents(params: InferSchema) { // ─── Helpers ───────────────────────────────────────────────────────────────── -function buildFilters(params: InferSchema): Record { +export function buildAppInspectorAnalyticsArgs(params: InferSchema) { + 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 formatAppInspectorHttpError(err: HttpError) { + if (err.status === 403 || err.status === 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(`HTTP ${err.status}`, err.body || err.message); +} + +function buildFilters( + params: InferSchema +): Record { + 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: InferSchema +): Record { 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 buildQueryString(params: Record): string { +function buildSpanFilters( + params: InferSchema +): Record { + 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: InferSchema +): Record { + 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: Record): string { const parts = Object.entries(params) .filter(([, v]) => v !== undefined) .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`); return parts.length ? `?${parts.join("&")}` : ""; } -function formatTraceList(data: any): string { +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."; @@ -319,17 +454,18 @@ function formatTraceList(data: any): string { const rows = traces .map((t: any) => { const id = t.trace_id ?? t.id ?? "-"; - const service = t.service_name ?? "-"; - const operation = t.operation_name ?? "-"; + 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} | ${service} | ${operation} | ${status} | ${start} |`; + return `| ${id} | ${services} | ${spans} | ${errors} | ${status} | ${start} |`; }) .join("\n"); let result = `## Traces (${traces.length})\n\n`; - result += `| Trace ID | Service | Operation | Status | Start Time |\n`; - result += `|---|---|---|---|---|\n`; + result += `| Trace ID | Services | Spans | Errors | Status | Start Time |\n`; + result += `|---|---|---|---|---|---|\n`; result += rows; if (data?.pagination) { @@ -340,7 +476,7 @@ function formatTraceList(data: any): string { return result; } -function formatSpanList(data: any, traceId: string): string { +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.`; @@ -369,7 +505,7 @@ function formatSpanList(data: any, traceId: string): string { return result; } -function formatEventList(data: any, spanId: string, prefix = ""): string { +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.`; From 9c2caf2dca95458ce089ad84ab5802b236b4ebdd Mon Sep 17 00:00:00 2001 From: Harsh Mishra Date: Wed, 29 Apr 2026 23:01:47 +0530 Subject: [PATCH 4/4] refactor app inspector api to http client --- .../localstack/app-inspector.logic.test.ts | 13 +- src/lib/localstack/localstack.client.ts | 117 +++++++++ src/tools/localstack-app-inspector.ts | 231 +++++++++--------- 3 files changed, 235 insertions(+), 126 deletions(-) diff --git a/src/lib/localstack/app-inspector.logic.test.ts b/src/lib/localstack/app-inspector.logic.test.ts index f7fe0ff..f5aad15 100644 --- a/src/lib/localstack/app-inspector.logic.test.ts +++ b/src/lib/localstack/app-inspector.logic.test.ts @@ -1,12 +1,11 @@ import { buildAppInspectorAnalyticsArgs, buildQueryString, - formatAppInspectorHttpError, + formatAppInspectorApiError, formatEventList, formatSpanList, formatTraceList, } from "../../tools/localstack-app-inspector"; -import { HttpError } from "../../core/http-client"; describe("localstack-app-inspector", () => { describe("buildQueryString", () => { @@ -91,11 +90,13 @@ describe("localstack-app-inspector", () => { }); }); - describe("formatAppInspectorHttpError", () => { + describe("formatAppInspectorApiError", () => { it("turns disabled App Inspector responses into actionable guidance", () => { - const formatted = formatAppInspectorHttpError( - new HttpError(503, "Service Unavailable", "AppInspector is not enabled", "disabled") - ); + 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"); 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 index 4613600..59d6948 100644 --- a/src/tools/localstack-app-inspector.ts +++ b/src/tools/localstack-app-inspector.ts @@ -1,6 +1,5 @@ import { z } from "zod"; import { type ToolMetadata, type InferSchema } from "xmcp"; -import { httpClient, HttpError } from "../core/http-client"; import { runPreflights, requireAuthToken, @@ -10,9 +9,11 @@ import { import { ResponseBuilder } from "../core/response-builder"; import { withToolAnalytics } from "../core/analytics"; import { ProFeature } from "../lib/localstack/license-checker"; - -const API_PREFIX = "/_localstack/appinspector"; -const API_V1 = `${API_PREFIX}/v1`; +import { + AppInspectorApiClient, + type ApiResult, + type AppInspectorQuery, +} from "../lib/localstack/localstack.client"; export const schema = { action: z @@ -116,6 +117,8 @@ export const schema = { .describe("Filter events by event type, e.g. iam.policy_evaluation."), }; +export type AppInspectorArgs = InferSchema; + export const metadata: ToolMetadata = { name: "localstack-app-inspector", description: @@ -128,7 +131,7 @@ export const metadata: ToolMetadata = { }, }; -export default async function localstackAppInspector(params: InferSchema) { +export default async function localstackAppInspector(params: AppInspectorArgs) { const { action } = params; return withToolAnalytics( "localstack-app-inspector", @@ -141,38 +144,33 @@ export default async function localstackAppInspector(params: InferSchema(`${API_PREFIX}/status`); - let result = `## App Inspector Status\n\n**Status:** ${data.status}`; - if (data.note) result += `\n\n${data.note}`; - return ResponseBuilder.markdown(result); +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(params: InferSchema) { +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 data = await httpClient.request<{ status: string; changed: boolean }>( - `${API_PREFIX}/status`, - { - method: "PUT", - body: JSON.stringify({ status: params.status }), - headers: { "Content-Type": "application/json" }, - } - ); - const changed = data.changed ? "Status changed." : "Status was already set."; + 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:** ${data.status}\n\n${changed}` + `## App Inspector Status Updated\n\n**Status:** ${result.data.status}\n\n${changed}` ); } // ─── Traces ────────────────────────────────────────────────────────────────── -async function handleListTraces(params: InferSchema) { - const qs = buildQueryString(buildTraceFilters(params)); - const data = await httpClient.request(`${API_V1}/traces${qs}`); - return ResponseBuilder.markdown(formatTraceList(data)); +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(params: InferSchema) { +async function handleGetTrace(client: AppInspectorApiClient, params: AppInspectorArgs) { if (!params.trace_id) { return ResponseBuilder.error("Missing Parameter", "`trace_id` is required for get-trace"); } - const data = await httpClient.request(`${API_V1}/traces/${params.trace_id}`); + 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(data, null, 2)}\n\`\`\`` + `## Trace: ${params.trace_id}\n\n\`\`\`json\n${JSON.stringify(result.data, null, 2)}\n\`\`\`` ); } -async function handleDeleteTraces(params: InferSchema) { - const body = params.trace_ids ? { trace_ids: params.trace_ids } : {}; - const data = await httpClient.request<{ deleted_count: number }>(`${API_V1}/traces`, { - method: "DELETE", - body: JSON.stringify(body), - headers: { "Content-Type": "application/json" }, - }); +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 ${data.deleted_count} trace(s) (requested: ${scope}).` + `## Traces Deleted\n\nDeleted ${result.data.deleted_count} trace(s) (requested: ${scope}).` ); } // ─── Spans ─────────────────────────────────────────────────────────────────── -async function handleListSpans(params: InferSchema) { +async function handleListSpans(client: AppInspectorApiClient, params: AppInspectorArgs) { if (!params.trace_id) { return ResponseBuilder.error("Missing Parameter", "`trace_id` is required for list-spans"); } - const qs = buildQueryString(buildSpanFilters(params)); - const data = await httpClient.request(`${API_V1}/traces/${params.trace_id}/spans${qs}`); - return ResponseBuilder.markdown(formatSpanList(data, params.trace_id)); + 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(params: InferSchema) { +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 data = await httpClient.request( - `${API_V1}/traces/${params.trace_id}/spans/${params.span_id}` - ); + 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(data, null, 2)}\n\`\`\`` + `## Span: ${params.span_id}\n\n\`\`\`json\n${JSON.stringify(result.data, null, 2)}\n\`\`\`` ); } -async function handleDeleteSpans(params: InferSchema) { +async function handleDeleteSpans(client: AppInspectorApiClient, params: AppInspectorArgs) { if (!params.trace_id) { return ResponseBuilder.error("Missing Parameter", "`trace_id` is required for delete-spans"); } - const body = params.span_ids ? { span_ids: params.span_ids } : {}; - const data = await httpClient.request<{ deleted_count: number }>( - `${API_V1}/traces/${params.trace_id}/spans`, - { - method: "DELETE", - body: JSON.stringify(body), - headers: { "Content-Type": "application/json" }, - } - ); + 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 ${data.deleted_count} span(s) (requested: ${scope}).` + `## Spans Deleted\n\nDeleted ${result.data.deleted_count} span(s) (requested: ${scope}).` ); } // ─── Events ────────────────────────────────────────────────────────────────── -async function handleListEvents(params: InferSchema) { +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 qs = buildQueryString(buildEventFilters(params)); - const data = await httpClient.request( - `${API_V1}/traces/${params.trace_id}/spans/${params.span_id}/events${qs}` + const result = await client.listEvents( + params.trace_id, + params.span_id, + buildEventFilters(params) ); - return ResponseBuilder.markdown(formatEventList(data, params.span_id)); + if (!result.success) return formatAppInspectorApiError(result); + return ResponseBuilder.markdown(formatEventList(result.data, params.span_id)); } -async function handleGetEvent(params: InferSchema) { +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 data = await httpClient.request( - `${API_V1}/traces/${params.trace_id}/spans/${params.span_id}/events/${params.event_id}` - ); + 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(data, null, 2)}\n\`\`\`` + `## Event: ${params.event_id}\n\n\`\`\`json\n${JSON.stringify(result.data, null, 2)}\n\`\`\`` ); } -async function handleListIamEvents(params: InferSchema) { +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 qs = buildQueryString(buildEventFilters(params)); - const data = await httpClient.request( - `${API_V1}/traces/${params.trace_id}/spans/${params.span_id}/events/iam${qs}` + 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 ") ); - return ResponseBuilder.markdown(formatEventList(data, params.span_id, "IAM Policy Evaluation ")); } // ─── Helpers ───────────────────────────────────────────────────────────────── -export function buildAppInspectorAnalyticsArgs(params: InferSchema) { +export function buildAppInspectorAnalyticsArgs(params: AppInspectorArgs) { const filterKeys = Object.entries(buildFilters(params)) .filter(([, value]) => value !== undefined) .map(([key]) => key) @@ -350,19 +344,22 @@ export function buildAppInspectorAnalyticsArgs(params: InferSchema) { + 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(`HTTP ${err.status}`, err.body || err.message); + return ResponseBuilder.error( + result.statusCode ? `HTTP ${result.statusCode}` : "App Inspector API Error", + result.message + ); } -function buildFilters( - params: InferSchema -): Record { +function buildFilters(params: AppInspectorArgs): AppInspectorQuery { return { limit: params.limit, pagination_token: params.pagination_token, @@ -385,9 +382,7 @@ function buildFilters( }; } -function buildTraceFilters( - params: InferSchema -): Record { +function buildTraceFilters(params: AppInspectorArgs): AppInspectorQuery { return { limit: params.limit, pagination_token: params.pagination_token, @@ -406,9 +401,7 @@ function buildTraceFilters( }; } -function buildSpanFilters( - params: InferSchema -): Record { +function buildSpanFilters(params: AppInspectorArgs): AppInspectorQuery { return { limit: params.limit, pagination_token: params.pagination_token, @@ -427,9 +420,7 @@ function buildSpanFilters( }; } -function buildEventFilters( - params: InferSchema -): Record { +function buildEventFilters(params: AppInspectorArgs): AppInspectorQuery { return { limit: params.limit, pagination_token: params.pagination_token, @@ -439,7 +430,7 @@ function buildEventFilters( }; } -export function buildQueryString(params: Record): string { +export function buildQueryString(params: AppInspectorQuery): string { const parts = Object.entries(params) .filter(([, v]) => v !== undefined) .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);