diff --git a/.github/workflows/azure-smoke.yml b/.github/workflows/azure-smoke.yml
new file mode 100644
index 0000000..2c12447
--- /dev/null
+++ b/.github/workflows/azure-smoke.yml
@@ -0,0 +1,47 @@
+name: Azure smoke
+
+# The Azure tool against the LocalStack Azure emulator, weekly and on demand.
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: "0 6 * * 1"
+
+permissions:
+ contents: read
+
+jobs:
+ azure-smoke:
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ env:
+ # The token's licence must include LocalStack for Azure, or the emulator refuses to start.
+ LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }}
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Use Node.js 22
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22.x
+
+ - name: Install dependencies
+ run: yarn
+
+ # ubuntu-latest ships the Azure CLI.
+ - name: Show the Azure CLI version
+ run: az version
+
+ - name: Pull the LocalStack Azure emulator image
+ run: docker pull localstack/localstack-azure:latest
+
+ - name: Run az commands through the Azure tool
+ run: yarn build && npx playwright test -c playwright.config.mjs tests/mcp/azure-smoke.spec.mjs
+
+ - name: Keep the test results of a failed run
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: azure-smoke-results
+ path: .mcp-test-results
+ if-no-files-found: ignore
diff --git a/README.md b/README.md
index 4b22ac0..17a2f8c 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,7 @@ Once the server is configured, talk to LocalStack through your agent in natural
- "My Lambda calls are failing. Read the LocalStack logs, find the permission errors, and generate an IAM policy that fixes them."
- "Inject 500ms of latency into DynamoDB and confirm my retry logic still works."
- "Search the LocalStack docs for how to enable S3 event notifications and summarize the steps."
+- "Start the LocalStack Azure emulator, create a resource group and a storage account, and upload `./data/sample.csv` to a new blob container."
## How it works
@@ -42,7 +43,7 @@ The server connects MCP-compatible apps directly to your local LocalStack enviro
This server eliminates custom scripts and manual LocalStack management. Your agent can:
-- Start, stop, restart, and monitor LocalStack for AWS container status with built-in auth.
+- Start, stop, restart, and monitor the LocalStack for AWS, Snowflake and Azure emulators with built-in auth.
- Deploy CDK, Terraform, and SAM projects with automatic configuration detection.
- Search LocalStack documentation for guides, API references, and configuration details.
- Parse logs, catch errors, and auto-generate IAM policies from violations.
@@ -64,7 +65,7 @@ This server provides your AI with dedicated tools for managing your LocalStack e
| 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-management`](./src/tools/localstack-management.ts) | Manages LocalStack runtime operations for AWS, Snowflake and Azure 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 |
@@ -74,6 +75,7 @@ This server provides your AI with dedicated tools for managing your LocalStack e
| [`localstack-extensions`](./src/tools/localstack-extensions.ts) | Installs, uninstalls, lists, and discovers LocalStack Extensions | - Manage installed extensions (`list`, `install`, `uninstall`) inside the running container
- Browse the LocalStack Extensions marketplace (`available`)
- Requires a valid LocalStack Auth Token |
| [`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 |
| [`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-azure-client`](./src/tools/localstack-azure-client.ts) | Runs Azure CLI (`az`) commands against the LocalStack for Azure emulator | - Runs the Azure CLI installed on your machine with its own profile, never your Azure login
- Blocks shell syntax and commands that would change the profile, install software or never end
- Uses the extensions you installed with `az extension add`
- Requires the Azure CLI and a valid LocalStack Auth Token |
| [`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 |
@@ -124,6 +126,7 @@ Run `npx -y @localstack/localstack-mcp-server init --help` for all options.
- Docker installed and running. The MCP server manages the LocalStack container directly through the Docker API.
- [`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 if you want to deploy CDK, Terraform, or SAM projects
- Snowflake CLI (`snow`) installed in your system path if you want to use the Snowflake tool
+- Azure CLI (`az`) installed in your system path if you want to use the Azure tool (see [docs/AZURE.md](./docs/AZURE.md))
- A [valid LocalStack Auth Token](https://docs.localstack.cloud/aws/getting-started/auth-token/) configured as `LOCALSTACK_AUTH_TOKEN` (**required for all MCP tools**)
- [Node.js v20](https://nodejs.org/en/download/) or higher installed in your system path
@@ -209,6 +212,8 @@ See **[docs/DOCKER.md](./docs/DOCKER.md)** for the run command, MCP client confi
| Variable Name | Description | Default Value |
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| `LOCALSTACK_AUTH_TOKEN` (**required**) | The LocalStack Auth Token to use for the MCP server | None |
+| `LOCALSTACK_HOSTNAME` | Host name of the LocalStack gateway the tools call; in Docker, `host.docker.internal`. The Azure tool always calls `azure.localhost.localstack.cloud` (127.0.0.1), so it needs a local gateway. | `localhost` |
+| `LOCALSTACK_PORT` | Port of the LocalStack gateway the tools call. | `4566` |
| `MAIN_CONTAINER_NAME` | The explicit LocalStack container name to use for Docker-based tools. When unset, the server auto-detects standard LocalStack containers such as `localstack-main` and `localstack-aws`, then LocalStack Docker images. | Auto-detect |
| `LOCALSTACK_IMAGE_NAME` / `IMAGE_NAME` | Docker image the `start` action launches for the AWS stack. `LOCALSTACK_IMAGE_NAME` wins when both are set (beware: bare `IMAGE_NAME` is a common CI variable). | `localstack/localstack-pro:latest` |
| `LOCALSTACK_VOLUME_DIR` | Host directory mounted at `/var/lib/localstack` in the LocalStack container. Defaults to the same per-OS cache path the LocalStack CLI used (state carries over); inside Docker it defaults to the `localstack-mcp` named volume. | Per-OS cache dir |
@@ -221,6 +226,13 @@ See **[docs/DOCKER.md](./docs/DOCKER.md)** for the run command, MCP client confi
| `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 |
+| `LOCALSTACK_AZURE_IMAGE_NAME` | Docker image the `start` action launches for the Azure stack (default `localstack/localstack-azure:latest`). | Latest Azure image |
+| `LOCALSTACK_AZ_PATH` | The `az` launcher, or the Azure CLI's Python, for the Azure tool. | Found on `PATH` |
+| `LOCALSTACK_AZ_CONFIG_DIR` | The Azure tool's own Azure CLI profile directory (default `~/.localstack/azure/mcp-config-`); it must not overlap your `~/.azure`. | Under `~/.localstack/azure` |
+| `LOCALSTACK_AZ_WORKDIR` | The directory the Azure tool runs `az` in; relative file paths resolve against it. | The server's working directory |
+| `LOCALSTACK_AZ_TIMEOUT_SECONDS` | Time limit for one Azure CLI command (5ā3600). | `300` |
+
+See **[docs/AZURE.md](./docs/AZURE.md)** for how the Azure tool runs the Azure CLI.
### Migration notes (CLI-free lifecycle)
diff --git a/data/sample-azure/hello.txt b/data/sample-azure/hello.txt
new file mode 100644
index 0000000..6b85822
--- /dev/null
+++ b/data/sample-azure/hello.txt
@@ -0,0 +1 @@
+hello from the LocalStack MCP server Azure stage
diff --git a/docs/AZURE.md b/docs/AZURE.md
new file mode 100644
index 0000000..37d0944
--- /dev/null
+++ b/docs/AZURE.md
@@ -0,0 +1,11 @@
+# Using the Azure tool
+
+`localstack-azure-client` runs the Azure CLI (`az`) installed on your machine against the LocalStack for Azure emulator; start the emulator with `localstack-management` (`service: azure`). Its settings are listed in the [README](../README.md#localstack-configuration).
+
+- The tool keeps its own Azure CLI profile (`LOCALSTACK_AZ_CONFIG_DIR`), logged in to the emulator with a dummy account, so your own Azure login is never used or changed.
+- It uses your Azure CLI extensions: install one with `az extension add --name `.
+- Bicep templates need the `bicep` binary on your `PATH` (for example `brew install bicep` or `winget install Microsoft.Bicep`).
+- Relative file paths in commands resolve against `LOCALSTACK_AZ_WORKDIR`, by default the directory the MCP client starts the server in.
+- `rest` calls the emulator: give it a relative URL. An absolute URL must name a local host; a `management.azure.com` URL is turned into a relative one.
+- Only `rest` URLs are checked: an endpoint given in a command, such as `--blob-endpoint` or a connection string, is used as it is.
+- The Docker image does not include the Azure CLI yet: run the server with npx or from source to use the Azure tool.
diff --git a/docs/DOCKER.md b/docs/DOCKER.md
index 0bff983..96657c8 100644
--- a/docs/DOCKER.md
+++ b/docs/DOCKER.md
@@ -125,6 +125,10 @@ alias covers bootstrap asset uploads.
on the host even if your editor restarts the MCP container ā reconnecting finds
your stack still up. State persists in the `localstack-mcp` named volume (or
`LOCALSTACK_VOLUME_DIR` if you set one).
+- **No Azure CLI.** The image lists all 15 tools, but it does not include the Azure
+ CLI, so `localstack-azure-client` answers that `az` is not available. To use the
+ Azure tool, run the server with npx or from source. `localstack-management` can
+ still start and stop the Azure emulator (`service: azure`) from the image.
## Troubleshooting
diff --git a/manifest.json b/manifest.json
index 75afc29..c8ac9b6 100644
--- a/manifest.json
+++ b/manifest.json
@@ -67,6 +67,10 @@
"name": "localstack-aws-client",
"description": "Runs AWS CLI commands inside the running LocalStack container"
},
+ {
+ "name": "localstack-azure-client",
+ "description": "Runs Azure CLI (az) commands against the LocalStack for Azure emulator"
+ },
{
"name": "localstack-aws-replicator",
"description": "Replicates external AWS resources into a running LocalStack instance using the AWS Replicator HTTP API"
@@ -156,6 +160,12 @@
"arguments": ["description"],
"text": "Please query the LocalStack container for ${arguments.description}."
},
+ {
+ "name": "localstack-azure-client",
+ "description": "Runs Azure CLI commands against the LocalStack Azure emulator",
+ "arguments": ["description"],
+ "text": "Please query the LocalStack Azure emulator for ${arguments.description}."
+ },
{
"name": "aws-replicator-start",
"description": "Start an AWS Replicator job",
diff --git a/package.json b/package.json
index 5878b32..88ba437 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,7 @@
"prepack": "yarn build",
"format": "prettier --write .",
"test": "jest",
- "test:mcp:direct": "yarn build && playwright test -c playwright.config.mjs tests/mcp/direct.spec.mjs",
+ "test:mcp:direct": "yarn build && playwright test -c playwright.config.mjs tests/mcp/direct.spec.mjs tests/mcp/azure-offline.spec.mjs",
"test:mcp:evals": "yarn build && playwright test -c playwright.config.mjs tests/mcp/evals-gemini.spec.mjs",
"test:mcp": "yarn test:mcp:direct && yarn test:mcp:evals"
},
diff --git a/playwright.config.mjs b/playwright.config.mjs
index f1123b7..d8f07d8 100644
--- a/playwright.config.mjs
+++ b/playwright.config.mjs
@@ -1,10 +1,19 @@
import { defineConfig } from "@playwright/test";
+import net from "node:net";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
const mcpCommand = process.env.MCP_TEST_COMMAND || "node";
const mcpArgs = process.env.MCP_TEST_ARGS
? process.env.MCP_TEST_ARGS.split(" ").filter(Boolean)
: ["dist/cli.js"];
+// A port where nothing listens, for the Azure tool's tests without an emulator.
+const probe = net.createServer();
+await new Promise((resolve) => probe.listen(0, "127.0.0.1", resolve));
+const freePort = probe.address().port;
+await new Promise((resolve) => probe.close(resolve));
+
export default defineConfig({
testDir: "./tests/mcp",
timeout: 120000,
@@ -23,6 +32,7 @@ export default defineConfig({
projects: [
{
name: "localstack-mcp-server",
+ testIgnore: /azure-offline\.spec\.mjs$/,
use: {
mcpConfig: {
transport: "stdio",
@@ -40,5 +50,30 @@ export default defineConfig({
},
},
},
+ {
+ // The Azure tool with no emulator: a dummy token, a port where nothing listens and a
+ // config dir of its own.
+ name: "azure-offline",
+ testMatch: /azure-offline\.spec\.mjs$/,
+ use: {
+ mcpConfig: {
+ transport: "stdio",
+ command: mcpCommand,
+ args: mcpArgs,
+ cwd: process.cwd(),
+ quiet: true,
+ connectTimeoutMs: 30000,
+ requestTimeoutMs: 300000,
+ callTimeoutMs: 300000,
+ env: {
+ ...process.env,
+ LOCALSTACK_AUTH_TOKEN: "ls-dummy-token-for-offline-tests",
+ LOCALSTACK_PORT: String(freePort),
+ LOCALSTACK_AZ_CONFIG_DIR: join(tmpdir(), "lsmcp-azure-offline"),
+ MCP_ANALYTICS_DISABLED: "1",
+ },
+ },
+ },
+ },
],
});
diff --git a/server.json b/server.json
index 88bbe54..ddd5473 100644
--- a/server.json
+++ b/server.json
@@ -157,6 +157,55 @@
"format": "string",
"isSecret": false,
"name": "EXTERNAL_SERVICE_PORTS_END"
+ },
+ {
+ "description": "Host name of the LocalStack gateway the tools call. Defaults to localhost; when the server runs in Docker, host.docker.internal.",
+ "isRequired": false,
+ "format": "string",
+ "isSecret": false,
+ "name": "LOCALSTACK_HOSTNAME"
+ },
+ {
+ "description": "Port of the LocalStack gateway the tools call. Defaults to 4566.",
+ "isRequired": false,
+ "format": "string",
+ "isSecret": false,
+ "name": "LOCALSTACK_PORT"
+ },
+ {
+ "description": "Docker image the management start action launches for service: azure. Defaults to localstack/localstack-azure:latest.",
+ "isRequired": false,
+ "format": "string",
+ "isSecret": false,
+ "name": "LOCALSTACK_AZURE_IMAGE_NAME"
+ },
+ {
+ "description": "The az launcher, or the Azure CLI's Python, for localstack-azure-client. Defaults to the first az on PATH.",
+ "isRequired": false,
+ "format": "string",
+ "isSecret": false,
+ "name": "LOCALSTACK_AZ_PATH"
+ },
+ {
+ "description": "The Azure tool's own Azure CLI profile directory; it must not overlap ~/.azure. Defaults to ~/.localstack/azure/mcp-config-.",
+ "isRequired": false,
+ "format": "string",
+ "isSecret": false,
+ "name": "LOCALSTACK_AZ_CONFIG_DIR"
+ },
+ {
+ "description": "The directory localstack-azure-client runs az in; relative file paths resolve against it. Defaults to the server's working directory.",
+ "isRequired": false,
+ "format": "string",
+ "isSecret": false,
+ "name": "LOCALSTACK_AZ_WORKDIR"
+ },
+ {
+ "description": "Time limit in seconds for one Azure CLI command (5-3600). Defaults to 300.",
+ "isRequired": false,
+ "format": "string",
+ "isSecret": false,
+ "name": "LOCALSTACK_AZ_TIMEOUT_SECONDS"
}
]
}
diff --git a/src/core/analytics.ts b/src/core/analytics.ts
index 522b50e..c3a5090 100644
--- a/src/core/analytics.ts
+++ b/src/core/analytics.ts
@@ -19,6 +19,7 @@ const SHUTDOWN_TIMEOUT_MS = 1000;
export const TOOL_ARG_ALLOWLIST: Record = {
"localstack-aws-client": ["command"],
+ "localstack-azure-client": ["command"],
"localstack-aws-replicator": [
"action",
"replication_type",
diff --git a/src/lib/azure/argv.ts b/src/lib/azure/argv.ts
new file mode 100644
index 0000000..2a55162
--- /dev/null
+++ b/src/lib/azure/argv.ts
@@ -0,0 +1,84 @@
+/**
+ * Quote-aware splitting of an `az` command string into an argv array. No shell is ever involved:
+ * the result goes to spawn as an argument vector, so this reproduces POSIX word splitting for a
+ * safe subset and refuses anything that would need a shell (pipes, chaining, redirects,
+ * substitutions).
+ *
+ * It is the AWS client's splitter with three changes that `az` commands need (JMESPath queries,
+ * empty values, JSON in double quotes):
+ * - newline, CR and backtick are refused only outside quotes; inside quotes they are data;
+ * - `""` and `''` produce an empty argument instead of disappearing;
+ * - inside double quotes, `\$`, `` \` `` and `\` behave as in bash.
+ *
+ * As there, an unquoted backslash is literal (Windows paths work), `$VAR` is passed literally, and
+ * an unquoted `#word` is an argument.
+ */
+
+export class AzSyntaxError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "AzSyntaxError";
+ }
+}
+
+export function splitAzArgs(command: string): string[] {
+ const args: string[] = [];
+ let current = "";
+ // True once the current word has started, even if it holds no characters yet ("").
+ let wordStarted = false;
+ let quote: "'" | '"' | undefined;
+
+ const endWord = () => {
+ if (wordStarted) args.push(current);
+ current = "";
+ wordStarted = false;
+ };
+
+ for (let index = 0; index < command.length; index++) {
+ const character = command[index];
+
+ if (!quote && (character === "\n" || character === "\r" || character === "`")) {
+ throw new AzSyntaxError("Command contains forbidden shell syntax.");
+ }
+
+ if (character === "\\" && quote === '"') {
+ const escaped = command[index + 1];
+ if (escaped === '"' || escaped === "\\" || escaped === "$" || escaped === "`") {
+ current += escaped;
+ index++;
+ } else if (escaped === "\n") {
+ index++; // a line continuation: bash drops both characters
+ } else {
+ current += character;
+ }
+ continue;
+ }
+
+ if ((character === "'" || character === '"') && (!quote || quote === character)) {
+ quote = quote ? undefined : character;
+ wordStarted = true;
+ continue;
+ }
+
+ if (!quote) {
+ if (
+ ";&|<>".includes(character) ||
+ command.startsWith("$(", index) ||
+ command.startsWith("${", index)
+ ) {
+ throw new AzSyntaxError("Command contains forbidden shell syntax.");
+ }
+ if (/\s/.test(character)) {
+ endWord();
+ continue;
+ }
+ }
+
+ current += character;
+ wordStarted = true;
+ }
+
+ if (quote) throw new AzSyntaxError("Command contains an unterminated quote.");
+ endWord();
+ return args;
+}
diff --git a/src/lib/azure/bootstrap.test.ts b/src/lib/azure/bootstrap.test.ts
new file mode 100644
index 0000000..d3bcf41
--- /dev/null
+++ b/src/lib/azure/bootstrap.test.ts
@@ -0,0 +1,116 @@
+import { mkdtempSync, readFileSync } from "fs";
+import os from "os";
+import path from "path";
+import {
+ BootstrapError,
+ CLI_CONFIG,
+ ensureProfile,
+ MARKER_FILE,
+ type BootstrapTarget,
+} from "./bootstrap";
+import type { AzRunOptions, AzRunResult } from "./runner";
+
+const ok = (stdout = ""): AzRunResult => ({
+ exitCode: 0,
+ stdout,
+ stderr: "",
+ timedOut: false,
+ aborted: false,
+ truncated: false,
+});
+
+function fakeAz(answers: Record = {}) {
+ const calls: string[][] = [];
+ const key = (argv: string[]) => (argv[0] === "login" ? "login" : argv.slice(0, 2).join(" "));
+ const run = jest.fn(async (argv: string[]) => {
+ calls.push(argv);
+ return answers[key(argv)] ?? ok();
+ });
+ return { run, calls, steps: () => calls.map(key) };
+}
+
+const target = (over: Partial = {}): BootstrapTarget => ({
+ configDir: mkdtempSync(path.join(os.tmpdir(), "lsmcp-bootstrap-")),
+ endpoint: "https://azure.localhost.localstack.cloud:4566",
+ sessionId: "s1",
+ azVersion: "2.91.0",
+ cwd: os.tmpdir(),
+ timeoutMs: 60_000,
+ ...over,
+});
+
+describe("ensureProfile", () => {
+ test("four az calls point the profile at the emulator, then the marker is written", async () => {
+ const az = fakeAz();
+ const t = target();
+ await ensureProfile(t, az.run);
+ expect(az.steps()).toEqual(["cloud register", "cloud set", "config set", "login"]);
+ const register = az.calls[0];
+ expect(
+ JSON.parse(register[register.indexOf("--cloud-config") + 1]).endpoints.resourceManager
+ ).toBe(`${t.endpoint}/`);
+ expect(az.calls[2]).toEqual(["config", "set", ...CLI_CONFIG, "--only-show-errors"]);
+ expect(az.calls[3]).toContain("--service-principal");
+ expect(JSON.parse(readFileSync(path.join(t.configDir, MARKER_FILE), "utf8"))).toEqual({
+ endpoint: t.endpoint,
+ sessionId: "s1",
+ azVersion: "2.91.0",
+ });
+ });
+
+ test("a cloud registered already (by an earlier run or another server) is updated", async () => {
+ const taken = {
+ ...ok(),
+ exitCode: 1,
+ stderr: "ERROR: The cloud 'LocalStack' is already registered.",
+ };
+ const az = fakeAz({ "cloud register": taken });
+ await ensureProfile(target(), az.run);
+ expect(az.steps()).toEqual([
+ "cloud register",
+ "cloud update",
+ "cloud set",
+ "config set",
+ "login",
+ ]);
+ });
+
+ test("concurrent calls share one run; it runs again only for another session", async () => {
+ const t = target();
+ const az = fakeAz();
+ await Promise.all([ensureProfile(t, az.run), ensureProfile(t, az.run)]);
+ await ensureProfile(t, az.run);
+ expect(az.calls).toHaveLength(4);
+ await ensureProfile({ ...t, sessionId: "s2" }, az.run);
+ expect(az.calls).toHaveLength(8);
+ });
+
+ test("a cancelled call stops the run it started; a call sharing it that did not cancel runs it again", async () => {
+ const t = target();
+ const cancel = new AbortController();
+ const run = jest.fn(async (_argv: string[], opts: AzRunOptions): Promise => {
+ if (opts.signal !== cancel.signal) return ok();
+ cancel.abort(); // the client cancels while the first step runs
+ return { ...ok(), exitCode: null, aborted: true };
+ });
+ const first = ensureProfile(t, run, cancel.signal).catch((error) => error);
+ const second = ensureProfile(t, run);
+ expect(await first).toMatchObject({ step: "cloud register", result: { aborted: true } });
+ await second;
+ expect(run).toHaveBeenCalledTimes(5); // the cancelled step, then a whole run for the second call
+ expect(readFileSync(path.join(t.configDir, MARKER_FILE), "utf8")).toContain("s1");
+ });
+
+ test("a failed step throws with az's output and leaves no marker, so the next call retries", async () => {
+ const t = target();
+ const failed = { ...ok(), exitCode: 1, stderr: "ERROR: bad" };
+ const az = fakeAz({ login: failed });
+ const error = await ensureProfile(t, az.run).catch((e) => e);
+ expect(error).toBeInstanceOf(BootstrapError);
+ expect(error).toMatchObject({ step: "login", result: failed });
+ expect(() => readFileSync(path.join(t.configDir, MARKER_FILE))).toThrow();
+ const retry = fakeAz();
+ await ensureProfile(t, retry.run);
+ expect(retry.calls).toHaveLength(4);
+ });
+});
diff --git a/src/lib/azure/bootstrap.ts b/src/lib/azure/bootstrap.ts
new file mode 100644
index 0000000..8f83b93
--- /dev/null
+++ b/src/lib/azure/bootstrap.ts
@@ -0,0 +1,150 @@
+import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
+import path from "path";
+import type { AzRunOptions, AzRunResult } from "./runner";
+
+/**
+ * The tool's own Azure CLI profile. Four `az` calls point its config dir at the emulator:
+ * `cloud register` (or `update`), `cloud set`, `config set` and a dummy `login`. The bootstrap is
+ * their only caller; the policy refuses them to the agent. A marker records the endpoint, emulator
+ * session and az version the profile was made for; when one changes (a restarted emulator has
+ * lost the dummy login), the bootstrap runs again. Concurrent calls in one server share one run.
+ */
+
+export const LOCALSTACK_CLOUD = "LocalStack";
+
+/** The CLI settings, set in one call. */
+export const CLI_CONFIG = [
+ "core.instance_discovery=false",
+ "core.collect_telemetry=false",
+ "output.show_survey_link=no",
+ // A command from an extension that is not installed fails instead of installing it.
+ "extension.use_dynamic_install=no",
+ // az sends failed command names, with their parameter names, to app.aladdin.microsoft.com.
+ "core.error_recommendation=off",
+ "core.output=json",
+ // Removes `vm create`'s region cost notice.
+ "core.display_region_identified=false",
+ // Bicep from PATH, and no aka.ms version lookups or downloads.
+ "bicep.use_binary_from_path=true",
+ "bicep.check_version=false",
+ "auto-upgrade.enable=no",
+];
+
+export const DUMMY_LOGIN =
+ "login --service-principal -u any-app -p any-pass --tenant anytenant --only-show-errors".split(
+ " "
+ );
+
+export function cloudConfigJson(endpoint: string): string {
+ return JSON.stringify({
+ endpoints: {
+ activeDirectory: endpoint,
+ activeDirectoryResourceId: endpoint,
+ activeDirectoryGraphResourceId: endpoint,
+ management: `${endpoint}/`,
+ microsoftGraphResourceId: `${endpoint}/`,
+ resourceManager: `${endpoint}/`,
+ logAnalyticsResourceId: endpoint,
+ },
+ });
+}
+
+export const MARKER_FILE = "localstack-mcp-bootstrap.json";
+
+export interface BootstrapTarget {
+ configDir: string;
+ endpoint: string;
+ sessionId?: string;
+ azVersion?: string;
+ cwd: string;
+ timeoutMs: number;
+}
+
+export type RunAz = (argv: string[], opts: AzRunOptions) => Promise;
+
+/** A bootstrap step failed; `result` carries az's output. */
+export class BootstrapError extends Error {
+ constructor(
+ readonly step: string,
+ readonly result: AzRunResult
+ ) {
+ super(
+ `Setting up the tool's Azure CLI profile failed at \`az ${step}\` (exit ${result.exitCode}).`
+ );
+ this.name = "BootstrapError";
+ }
+}
+
+const markerFor = (target: BootstrapTarget) =>
+ JSON.stringify({
+ endpoint: target.endpoint,
+ sessionId: target.sessionId ?? null,
+ // An upgraded az sets the profile up again, in case it reads an older az's profile differently.
+ azVersion: target.azVersion ?? null,
+ });
+
+function readMarker(configDir: string): string | undefined {
+ try {
+ return readFileSync(path.join(configDir, MARKER_FILE), "utf8");
+ } catch {
+ return undefined;
+ }
+}
+
+const inFlight = new Map>();
+
+/**
+ * Sets the profile up for this target, unless its marker says it already is. Concurrent calls
+ * share one run, which stops when the call that started it is cancelled; the others start again.
+ */
+export async function ensureProfile(
+ target: BootstrapTarget,
+ run: RunAz,
+ signal?: AbortSignal
+): Promise {
+ mkdirSync(target.configDir, { recursive: true, mode: 0o700 });
+ const marker = markerFor(target);
+ const key = `${target.configDir}\n${marker}`;
+ for (;;) {
+ if (readMarker(target.configDir) === marker) return;
+ let job = inFlight.get(key);
+ if (!job) {
+ job = runBootstrap(target, run, marker, signal).finally(() => inFlight.delete(key));
+ inFlight.set(key, job);
+ }
+ try {
+ return await job;
+ } catch (error) {
+ const cancelled = error instanceof BootstrapError && error.result.aborted;
+ if (!cancelled || signal?.aborted) throw error;
+ }
+ }
+}
+
+async function runBootstrap(
+ target: BootstrapTarget,
+ run: RunAz,
+ marker: string,
+ signal?: AbortSignal
+): Promise {
+ const opts: AzRunOptions = { timeoutMs: target.timeoutMs, cwd: target.cwd, signal };
+ // The marker is written last, so a partial bootstrap leaves none and the next call starts over.
+ rmSync(path.join(target.configDir, MARKER_FILE), { force: true });
+ const step = async (name: string, argv: string[]) => {
+ const result = await run(argv, opts);
+ if (result.exitCode !== 0) throw new BootstrapError(name, result);
+ };
+ // The cloud exists after an earlier bootstrap, or when another server registered it a moment ago.
+ const cloud = ["--name", LOCALSTACK_CLOUD, "--cloud-config", cloudConfigJson(target.endpoint)];
+ const registered = await run(["cloud", "register", ...cloud, "--only-show-errors"], opts);
+ if (registered.exitCode !== 0) {
+ if (!/is already registered/.test(registered.stderr)) {
+ throw new BootstrapError("cloud register", registered);
+ }
+ await step("cloud update", ["cloud", "update", ...cloud, "--only-show-errors"]);
+ }
+ await step("cloud set", ["cloud", "set", "--name", LOCALSTACK_CLOUD, "--only-show-errors"]);
+ await step("config set", ["config", "set", ...CLI_CONFIG, "--only-show-errors"]);
+ await step("login", DUMMY_LOGIN);
+ writeFileSync(path.join(target.configDir, MARKER_FILE), marker, { mode: 0o600 });
+}
diff --git a/src/lib/azure/child-env.test.ts b/src/lib/azure/child-env.test.ts
new file mode 100644
index 0000000..2992b0f
--- /dev/null
+++ b/src/lib/azure/child-env.test.ts
@@ -0,0 +1,72 @@
+import { buildAzChildEnv } from "./child-env";
+
+const config = {
+ configDir: "/cfg",
+ homeDir: "/cfg/home",
+ tmpDir: "/cfg/tmp",
+ extensionDir: "/home/u/.azure/cliextensions",
+};
+const launcher = { file: "/usr/bin/az", prefixArgs: [] };
+
+describe("buildAzChildEnv", () => {
+ test("an allow-list: nothing that points az elsewhere reaches it", () => {
+ const parent = {
+ PATH: "/usr/bin",
+ LANG: "en_US.UTF-8",
+ LC_TIME: "C",
+ HOME: "/home/u",
+ AZURE_CONFIG_DIR: "/home/u/.azure",
+ AZURE_CLIENT_SECRET: "s",
+ ARM_SUBSCRIPTION_ID: "x",
+ HTTPS_PROXY: "http://proxy:3128",
+ LOCALSTACK_AUTH_TOKEN: "ls-secret",
+ };
+ expect(buildAzChildEnv(parent, "linux", config, launcher)).toEqual({
+ PATH: "/usr/bin",
+ LANG: "en_US.UTF-8",
+ LC_TIME: "C",
+ HOME: "/cfg/home",
+ USERPROFILE: "/cfg/home",
+ TEMP: "/cfg/tmp",
+ TMP: "/cfg/tmp",
+ TMPDIR: "/cfg/tmp",
+ AZURE_CONFIG_DIR: "/cfg",
+ AZURE_EXTENSION_DIR: "/home/u/.azure/cliextensions",
+ AZURE_CORE_COLLECT_TELEMETRY: "no",
+ AZURE_CORE_NO_COLOR: "1",
+ });
+ });
+
+ test("a launcher without a UTF-8 locale gets C.UTF-8", () => {
+ expect(buildAzChildEnv({}, "linux", config, launcher).LC_ALL).toBe("C.UTF-8");
+ const python = { file: "/opt/az/bin/python3", prefixArgs: ["-IBm"] };
+ expect(buildAzChildEnv({}, "linux", config, python).LC_ALL).toBeUndefined();
+ });
+
+ test("az found off PATH: its directory is added, so bicep beside it is found", () => {
+ const brew = { file: "/opt/homebrew/bin/az", prefixArgs: [] };
+ expect(buildAzChildEnv({ PATH: "/usr/bin:/bin" }, "darwin", config, brew).PATH).toBe(
+ "/usr/bin:/bin:/opt/homebrew/bin"
+ );
+ expect(buildAzChildEnv({}, "darwin", config, brew).PATH).toBe("/opt/homebrew/bin");
+ });
+
+ test("Windows: case-insensitive names, the whole private home set, and AZ_INSTALLER", () => {
+ const home = "C:\\cfg\\home";
+ const env = buildAzChildEnv(
+ { Path: "C:\\Windows", SystemRoot: "C:\\Windows", APPDATA: "C:\\Users\\u\\AppData" },
+ "win32",
+ { ...config, homeDir: home, tmpDir: "C:\\cfg\\tmp" },
+ { file: "C:\\az\\python.exe", prefixArgs: ["-IBm", "azure.cli"], azInstaller: "MSI" }
+ );
+ expect(env).toMatchObject({
+ PATH: "C:\\Windows",
+ SYSTEMROOT: "C:\\Windows",
+ USERPROFILE: home,
+ HOMEDRIVE: "C:",
+ HOMEPATH: "\\cfg\\home",
+ APPDATA: `${home}\\AppData\\Roaming`,
+ AZ_INSTALLER: "MSI",
+ });
+ });
+});
diff --git a/src/lib/azure/child-env.ts b/src/lib/azure/child-env.ts
new file mode 100644
index 0000000..a7916d2
--- /dev/null
+++ b/src/lib/azure/child-env.ts
@@ -0,0 +1,89 @@
+import { mkdirSync } from "fs";
+import path from "path";
+import type { AzureConfig } from "./config";
+import type { AzExecutable } from "./resolve-az";
+
+/**
+ * The environment `az` runs with. It is built from an allow-list, never from a copy of the
+ * server's environment: `AZURE_*`, `ARM_*`, `MSI_*` and the proxy and CA variables would point
+ * `az` at real Azure or at another profile. Its home and temp directories are private ones inside
+ * the tool's config dir, so commands that write to `~` (`--generate-ssh-keys`,
+ * `aks get-credentials`) write there instead of into the user's home.
+ */
+
+type ChildConfig = Pick;
+
+const INHERITED = ["PATH", "SYSTEMROOT", "SYSTEMDRIVE", "WINDIR", "COMSPEC", "LANG"];
+const INHERITED_WINDOWS = [
+ "PATHEXT",
+ "USERNAME",
+ "COMPUTERNAME",
+ "NUMBER_OF_PROCESSORS",
+ "PROCESSOR_ARCHITECTURE",
+];
+
+const appData = (homeDir: string) => ({
+ roaming: path.win32.join(homeDir, "AppData", "Roaming"),
+ local: path.win32.join(homeDir, "AppData", "Local"),
+});
+
+/** The config dir and the private home and temp in it, owner-only. */
+export function ensurePrivateDirs(config: ChildConfig, platform: NodeJS.Platform): void {
+ const dirs = [config.configDir, config.homeDir, config.tmpDir];
+ if (platform === "win32") dirs.push(...Object.values(appData(config.homeDir)));
+ for (const dir of dirs) mkdirSync(dir, { recursive: true, mode: 0o700 });
+}
+
+export function buildAzChildEnv(
+ parent: NodeJS.ProcessEnv,
+ platform: NodeJS.Platform,
+ config: ChildConfig,
+ az: Pick
+): Record {
+ const env: Record = {};
+ const win = platform === "win32";
+ for (const name of win ? [...INHERITED, ...INHERITED_WINDOWS] : INHERITED) {
+ const key = win ? Object.keys(parent).find((k) => k.toUpperCase() === name) : name;
+ const value = key === undefined ? undefined : parent[key];
+ if (value !== undefined) env[name] = value;
+ }
+ // POSIX: `az` found off PATH (LOCALSTACK_AZ_PATH, or an install location when a GUI client starts
+ // the server with a short PATH): its directory joins PATH, so `bicep` installed beside it is found.
+ if (!win) {
+ const dir = path.posix.dirname(az.file);
+ const entries = (env.PATH ?? "").split(":").filter(Boolean);
+ if (!entries.includes(dir)) env.PATH = [...entries, dir].join(":");
+ }
+ for (const [name, value] of Object.entries(parent)) {
+ if (/^LC_[A-Z]+$/.test(name) && value !== undefined) env[name] = value;
+ }
+
+ // Always set: on Windows libuv copies HOMEDRIVE, HOMEPATH, TEMP, USERPROFILE and the rest of
+ // its required set from the parent when the block lacks them, which would undo the private home.
+ env.HOME = config.homeDir;
+ env.USERPROFILE = config.homeDir;
+ env.TEMP = config.tmpDir;
+ env.TMP = config.tmpDir;
+ if (win) {
+ env.HOMEDRIVE = path.win32.parse(config.homeDir).root.replace(/[\\/]$/, "");
+ env.HOMEPATH = config.homeDir.slice(env.HOMEDRIVE.length) || "\\";
+ env.APPDATA = appData(config.homeDir).roaming;
+ env.LOCALAPPDATA = appData(config.homeDir).local;
+ } else {
+ env.TMPDIR = config.tmpDir;
+ }
+
+ env.AZURE_CONFIG_DIR = config.configDir;
+ env.AZURE_EXTENSION_DIR = config.extensionDir;
+ env.AZURE_CORE_COLLECT_TELEMETRY = "no";
+ env.AZURE_CORE_NO_COLOR = "1";
+ if (az.azInstaller) env.AZ_INSTALLER = az.azInstaller;
+ // A launcher run as it is gets no `-X utf8`: ask for UTF-8 through the locale instead.
+ if (
+ az.prefixArgs.length === 0 &&
+ !["LC_ALL", "LC_CTYPE", "LANG"].some((k) => /utf-?8/i.test(env[k] ?? ""))
+ ) {
+ env.LC_ALL = "C.UTF-8";
+ }
+ return env;
+}
diff --git a/src/lib/azure/config.test.ts b/src/lib/azure/config.test.ts
new file mode 100644
index 0000000..07fffa6
--- /dev/null
+++ b/src/lib/azure/config.test.ts
@@ -0,0 +1,72 @@
+import { getAzureConfig, isLocalHost } from "./config";
+
+const HOME = "/home/u";
+const deps = {
+ homedir: () => HOME,
+ cwd: () => "/work",
+ platform: "linux" as const,
+ isDirectory: (p: string) => p === "/work" || p === "/home/u/proj",
+};
+const config = (env: NodeJS.ProcessEnv) => getAzureConfig(env, deps);
+
+describe("getAzureConfig", () => {
+ test("defaults: the gateway every tool uses, the emulator's ARM host, a per-port profile", () => {
+ expect(config({})).toEqual({
+ healthBaseUrl: "http://localhost:4566",
+ port: 4566,
+ endpoint: "https://azure.localhost.localstack.cloud:4566",
+ configDir: "/home/u/.localstack/azure/mcp-config-4566",
+ homeDir: "/home/u/.localstack/azure/mcp-config-4566/home",
+ tmpDir: "/home/u/.localstack/azure/mcp-config-4566/tmp",
+ extensionDir: "/home/u/.azure/cliextensions",
+ azPath: undefined,
+ timeoutMs: 300_000,
+ workdir: "/work",
+ errors: [],
+ });
+ });
+
+ test("LOCALSTACK_HOSTNAME and LOCALSTACK_PORT move the gateway and the endpoint's port", () => {
+ const c = config({ LOCALSTACK_HOSTNAME: "127.0.0.1", LOCALSTACK_PORT: "4666" });
+ expect(c.healthBaseUrl).toBe("http://127.0.0.1:4666");
+ expect(c.endpoint).toBe("https://azure.localhost.localstack.cloud:4666");
+ expect(config({ LOCALSTACK_PORT: "abc" }).errors[0]).toContain("LOCALSTACK_PORT");
+ });
+
+ test("the extension dir is the user's own: AZURE_EXTENSION_DIR, else their config dir's", () => {
+ expect(config({ AZURE_EXTENSION_DIR: "~/ext" }).extensionDir).toBe("/home/u/ext");
+ expect(config({ AZURE_CONFIG_DIR: "/cfg" }).extensionDir).toBe("/cfg/cliextensions");
+ });
+
+ test("isLocalHost: LocalStack's names for 127.0.0.1 and the loopback names only", () => {
+ for (const host of ["localhost", "127.0.0.1", "[::1]", "Azure.LocalHost.LocalStack.Cloud."]) {
+ expect(isLocalHost(host)).toBe(true);
+ }
+ for (const host of ["example.com", "localhost.localstack.cloud.evil.example", "10.0.0.5"]) {
+ expect(isLocalHost(host)).toBe(false);
+ }
+ });
+
+ test.each(["~/.azure", "~/.azure/sub", "~", "/cfg/inner"])(
+ "refuses a config dir that overlaps an Azure CLI profile: %s",
+ (dir) => {
+ const c = config({ LOCALSTACK_AZ_CONFIG_DIR: dir, AZURE_CONFIG_DIR: "/cfg" });
+ expect(c.errors.join(" ")).toContain("overlaps your Azure CLI profile");
+ }
+ );
+
+ test("the workdir must exist; ~ is expanded", () => {
+ expect(config({ LOCALSTACK_AZ_WORKDIR: "~/proj" })).toMatchObject({
+ workdir: "/home/u/proj",
+ errors: [],
+ });
+ expect(config({ LOCALSTACK_AZ_WORKDIR: "/nope" }).errors[0]).toContain("LOCALSTACK_AZ_WORKDIR");
+ });
+
+ test("the timeout is a whole number of seconds from 5 to 3600", () => {
+ expect(config({ LOCALSTACK_AZ_TIMEOUT_SECONDS: "60" }).timeoutMs).toBe(60_000);
+ for (const value of ["4", "3601", "1.5", "x"]) {
+ expect(config({ LOCALSTACK_AZ_TIMEOUT_SECONDS: value }).errors).toHaveLength(1);
+ }
+ });
+});
diff --git a/src/lib/azure/config.ts b/src/lib/azure/config.ts
new file mode 100644
index 0000000..97a090c
--- /dev/null
+++ b/src/lib/azure/config.ts
@@ -0,0 +1,122 @@
+import { statSync } from "fs";
+import os from "os";
+import path from "path";
+
+/** The Azure client tool's settings, read from the server's environment. */
+export interface AzureConfig {
+ /** The emulator's gateway, built like LOCALSTACK_BASE_URL: LOCALSTACK_HOSTNAME and LOCALSTACK_PORT. */
+ healthBaseUrl: string;
+ port: number;
+ /** The ARM endpoint `az` talks to: https://azure.localhost.localstack.cloud:. */
+ endpoint: string;
+ /** The tool's own Azure CLI config dir; `az` never sees the user's ~/.azure. */
+ configDir: string;
+ /** The child's private home and temp dir, inside the config dir. */
+ homeDir: string;
+ tmpDir: string;
+ /** The user's own extension dir: AZURE_EXTENSION_DIR, else /cliextensions. */
+ extensionDir: string;
+ /** An explicit `az` launcher or the CLI's Python (LOCALSTACK_AZ_PATH). */
+ azPath?: string;
+ timeoutMs: number;
+ /** The working directory `az` runs in (LOCALSTACK_AZ_WORKDIR, default the server's). */
+ workdir: string;
+ /** Settings the tool refuses to run with, until they are fixed. */
+ errors: string[];
+}
+
+export interface AzureConfigDeps {
+ homedir?: () => string;
+ cwd?: () => string;
+ platform?: NodeJS.Platform;
+ isDirectory?: (p: string) => boolean;
+}
+
+/**
+ * The hosts an absolute `rest` URL may name: LocalStack's public DNS names for 127.0.0.1
+ * (localhost.localstack.cloud and its subdomains) and the loopback names and addresses.
+ */
+export function isLocalHost(host: string): boolean {
+ const h = host
+ .toLowerCase()
+ .replace(/\.$/, "")
+ .replace(/^\[(.*)\]$/, "$1");
+ return (
+ ["localhost", "127.0.0.1", "::1", "localhost.localstack.cloud"].includes(h) ||
+ h.endsWith(".localhost.localstack.cloud")
+ );
+}
+
+function isDirectory(p: string): boolean {
+ try {
+ return statSync(p).isDirectory();
+ } catch {
+ return false;
+ }
+}
+
+export function getAzureConfig(
+ env: NodeJS.ProcessEnv = process.env,
+ deps: AzureConfigDeps = {}
+): AzureConfig {
+ const platform = deps.platform ?? process.platform;
+ const P = platform === "win32" ? path.win32 : path.posix;
+ const home = (deps.homedir ?? os.homedir)();
+ const cwd = (deps.cwd ?? process.cwd)();
+ const errors: string[] = [];
+ const setting = (name: string) => env[name]?.trim() || undefined;
+ // MCP client configs pass values without a shell, so a leading `~` is expanded here.
+ const userPath = (value: string) =>
+ P.resolve(cwd, value === "~" || /^~[\\/]/.test(value) ? P.join(home, value.slice(1)) : value);
+ const wholeNumber = (name: string, fallback: string, min: number, max: number) => {
+ const text = setting(name) ?? fallback;
+ const value = /^\d+$/.test(text) ? Number(text) : NaN;
+ if (!(value >= min && value <= max)) {
+ errors.push(`${name} must be a whole number from ${min} to ${max}; got "${text}".`);
+ }
+ return value;
+ };
+ const fold = (p: string) => (platform === "win32" || platform === "darwin" ? p.toLowerCase() : p);
+ const inside = (child: string, parent: string) => {
+ const rel = P.relative(fold(parent), fold(child));
+ return rel === "" || (!rel.startsWith("..") && !P.isAbsolute(rel));
+ };
+
+ const port = wholeNumber("LOCALSTACK_PORT", "4566", 1, 65535);
+
+ const userProfile = userPath(setting("AZURE_CONFIG_DIR") ?? P.join(home, ".azure"));
+ const configSetting = setting("LOCALSTACK_AZ_CONFIG_DIR");
+ const configDir = configSetting
+ ? userPath(configSetting)
+ : P.join(home, ".localstack", "azure", `mcp-config-${port}`);
+ // The bootstrap rewrites the config dir's cloud, settings and login: it must never be (or hold)
+ // the user's own Azure CLI profile.
+ for (const profile of new Set([P.join(home, ".azure"), userProfile])) {
+ if (inside(configDir, profile) || inside(profile, configDir)) {
+ errors.push(
+ `LOCALSTACK_AZ_CONFIG_DIR (${configDir}) overlaps your Azure CLI profile (${profile}). The tool keeps its own profile so it never changes your Azure CLI login; choose another directory.`
+ );
+ }
+ }
+
+ const workdir = userPath(setting("LOCALSTACK_AZ_WORKDIR") ?? cwd);
+ if (!(deps.isDirectory ?? isDirectory)(workdir)) {
+ errors.push(
+ `LOCALSTACK_AZ_WORKDIR (${workdir}) is not an existing directory: set it to the directory that holds your templates and files.`
+ );
+ }
+
+ return {
+ healthBaseUrl: `http://${setting("LOCALSTACK_HOSTNAME") ?? "localhost"}:${port}`,
+ port,
+ endpoint: `https://azure.localhost.localstack.cloud:${port}`,
+ configDir,
+ homeDir: P.join(configDir, "home"),
+ tmpDir: P.join(configDir, "tmp"),
+ extensionDir: userPath(setting("AZURE_EXTENSION_DIR") ?? P.join(userProfile, "cliextensions")),
+ azPath: setting("LOCALSTACK_AZ_PATH"),
+ timeoutMs: (wholeNumber("LOCALSTACK_AZ_TIMEOUT_SECONDS", "300", 5, 3600) || 300) * 1000,
+ workdir,
+ errors,
+ };
+}
diff --git a/src/lib/azure/emulator.test.ts b/src/lib/azure/emulator.test.ts
new file mode 100644
index 0000000..6fa631b
--- /dev/null
+++ b/src/lib/azure/emulator.test.ts
@@ -0,0 +1,101 @@
+import { getAzureEmulatorStatus, resetEmulatorStatusCache, type EmulatorDeps } from "./emulator";
+
+const config = {
+ healthBaseUrl: "http://localhost:4566",
+ endpoint: "https://azure.localhost.localstack.cloud:4566",
+};
+const AZURE_HEALTH = { edition: "azure-alpha", license: true };
+
+function deps(over: Partial & { health?: unknown; https?: boolean[] } = {}) {
+ const https = [...(over.https ?? [true])];
+ let now = 0;
+ const fake: EmulatorDeps = {
+ getJson: jest.fn(async (url: string) =>
+ url.endsWith("/health") ? over.health : { session_id: "s1", version: "4.9.0" }
+ ),
+ httpsReady: jest.fn(async () => https.shift() ?? false),
+ lookup: jest.fn(async () => ["127.0.0.1"]),
+ sleep: jest.fn(async (ms: number) => {
+ now += ms;
+ }),
+ now: () => now,
+ ...over,
+ };
+ return fake;
+}
+
+describe("getAzureEmulatorStatus", () => {
+ beforeEach(() => resetEmulatorStatusCache());
+
+ test("ready: the Azure edition, HTTPS answering, the name on loopback", async () => {
+ const d = deps({ health: AZURE_HEALTH });
+ expect(await getAzureEmulatorStatus(config, d)).toEqual({
+ ok: true,
+ edition: "azure-alpha",
+ sessionId: "s1",
+ version: "4.9.0",
+ });
+ expect(d.httpsReady).toHaveBeenCalledWith(
+ "127.0.0.1",
+ 4566,
+ "azure.localhost.localstack.cloud",
+ 5000
+ );
+ expect(d.getJson).toHaveBeenCalledWith("http://localhost:4566/_localstack/health", 5000);
+ });
+
+ test("nothing answering, another emulator on the port, or no licence", async () => {
+ const status = await getAzureEmulatorStatus(config, deps({ health: undefined }));
+ expect(status).toMatchObject({ ok: false, problem: "not-running" });
+ expect(status.message).toContain("not running at http://localhost:4566");
+ expect(status.message).toContain("service: azure");
+ const aws = await getAzureEmulatorStatus(config, deps({ health: { edition: "pro" } }));
+ expect(aws).toMatchObject({ problem: "wrong-edition", edition: "pro" });
+ const unlicensed = { health: { edition: "azure-alpha", license: false } };
+ expect((await getAzureEmulatorStatus(config, deps(unlicensed))).problem).toBe("license");
+ });
+
+ test("waits up to 10 s for HTTPS, then says it is still starting", async () => {
+ const late = deps({ health: AZURE_HEALTH, https: [false, false, true] });
+ expect((await getAzureEmulatorStatus(config, late)).ok).toBe(true);
+ resetEmulatorStatusCache(); // session s1 passed: forget it
+ const never = deps({ health: AZURE_HEALTH, https: [] });
+ const status = await getAzureEmulatorStatus(config, never);
+ expect(status).toMatchObject({ ok: false, problem: "https-not-ready" });
+ expect(never.sleep).toHaveBeenCalledTimes(20);
+ expect(status.message).toContain("did not become ready within 10 s");
+ const once = deps({ health: AZURE_HEALTH, https: [] });
+ const single = await getAzureEmulatorStatus(config, once, { httpsWaitMs: 0 });
+ expect(once.httpsReady).toHaveBeenCalledTimes(1);
+ expect(single.message).toContain("is not ready yet");
+ });
+
+ test("HTTPS is checked on the gateway LOCALSTACK_HOSTNAME names, with the endpoint as SNI", async () => {
+ const d = deps({ health: AZURE_HEALTH });
+ await getAzureEmulatorStatus(
+ { ...config, healthBaseUrl: "http://host.docker.internal:4566" },
+ d
+ );
+ expect(d.httpsReady).toHaveBeenCalledWith(
+ "host.docker.internal",
+ 4566,
+ "azure.localhost.localstack.cloud",
+ 5000
+ );
+ });
+
+ test("the name must resolve to loopback; a session that passed is not checked again", async () => {
+ const blocked = deps({ health: AZURE_HEALTH, lookup: jest.fn(async () => []) });
+ expect((await getAzureEmulatorStatus(config, blocked)).message).toContain("did not resolve");
+ const elsewhere = deps({ health: AZURE_HEALTH, lookup: jest.fn(async () => ["10.0.0.5"]) });
+ expect((await getAzureEmulatorStatus(config, elsewhere)).message).toContain(
+ "resolved to 10.0.0.5"
+ );
+ // Once a session has passed, HTTPS and DNS are not checked again.
+ const d = deps({ health: AZURE_HEALTH, https: [true, true] });
+ await getAzureEmulatorStatus(config, d);
+ await getAzureEmulatorStatus(config, d);
+ expect(d.httpsReady).toHaveBeenCalledTimes(1);
+ expect(d.lookup).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/lib/azure/emulator.ts b/src/lib/azure/emulator.ts
new file mode 100644
index 0000000..d965de1
--- /dev/null
+++ b/src/lib/azure/emulator.ts
@@ -0,0 +1,183 @@
+import { promises as dns } from "dns";
+import https from "https";
+import type { AzureConfig } from "./config";
+
+/**
+ * Is the LocalStack Azure emulator usable? Its health and info are read over plain HTTP from the
+ * gateway every tool uses. The ARM endpoint's HTTPS listener comes up seconds after it, and is
+ * checked on that same gateway with the endpoint's name as SNI. That name must also resolve to
+ * 127.0.0.1, which some routers' DNS-rebinding protection blocks.
+ */
+
+export type EmulatorProblem =
+ "not-running" | "wrong-edition" | "license" | "https-not-ready" | "dns";
+
+export interface AzureEmulatorStatus {
+ ok: boolean;
+ problem?: EmulatorProblem;
+ /** The user-facing explanation when `ok` is false. */
+ message?: string;
+ edition?: string;
+ version?: string;
+ /** Changes whenever the emulator restarts; the profile is set up again on a change. */
+ sessionId?: string;
+}
+
+export interface EmulatorDeps {
+ /** GET a JSON document over plain HTTP; undefined when nothing answers. */
+ getJson(url: string, timeoutMs: number): Promise;
+ /** True when an HTTPS server answers at address:port for `servername`. */
+ httpsReady(
+ address: string,
+ port: number,
+ servername: string,
+ timeoutMs: number
+ ): Promise;
+ lookup(host: string): Promise;
+ sleep(ms: number): Promise;
+ now(): number;
+}
+
+const HEALTH_TIMEOUT_MS = 5000;
+const HTTPS_READY_WAIT_MS = 10_000;
+const HTTPS_RETRY_MS = 500;
+const START_ADVICE =
+ "Start it with `localstack-management` (action: start, service: azure), then retry. If it runs on another port, set LOCALSTACK_PORT.";
+
+async function getJson(url: string, timeoutMs: number): Promise {
+ try {
+ const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
+ return await response.json().catch(() => ({}));
+ } catch {
+ return undefined;
+ }
+}
+
+function httpsReady(
+ address: string,
+ port: number,
+ servername: string,
+ timeoutMs: number
+): Promise {
+ return new Promise((resolve) => {
+ const request = https.request(
+ {
+ host: address,
+ port,
+ servername,
+ path: "/_localstack/health",
+ headers: { Host: `${servername}:${port}` },
+ agent: false,
+ timeout: timeoutMs,
+ // Only readiness is checked and nothing is sent; az verifies the certificate on every call.
+ rejectUnauthorized: false,
+ },
+ (response) => {
+ response.resume();
+ resolve(true);
+ }
+ );
+ request.on("timeout", () => request.destroy());
+ request.on("error", () => resolve(false));
+ request.end();
+ });
+}
+
+export const defaultEmulatorDeps: EmulatorDeps = {
+ getJson,
+ httpsReady,
+ lookup: async (host) => (await dns.lookup(host, { all: true })).map((a) => a.address),
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
+ now: () => Date.now(),
+};
+
+const isLoopback = (address: string) =>
+ /^127\./.test(address) || address === "::1" || /^::ffff:127\./i.test(address);
+
+/** The emulator session whose HTTPS endpoint and DNS name were checked. */
+let verifiedSession: string | undefined;
+
+export function resetEmulatorStatusCache(): void {
+ verifiedSession = undefined;
+}
+
+export async function getAzureEmulatorStatus(
+ config: Pick,
+ deps: EmulatorDeps = defaultEmulatorDeps,
+ /** `localstack-management` polls on its own, so it asks for a single HTTPS probe. */
+ opts: { httpsWaitMs?: number } = {}
+): Promise {
+ const base = config.healthBaseUrl;
+ const health = (await deps.getJson(`${base}/_localstack/health`, HEALTH_TIMEOUT_MS)) as
+ { edition?: unknown; license?: unknown } | undefined;
+ if (!health) {
+ return {
+ ok: false,
+ problem: "not-running",
+ message: `The LocalStack Azure emulator is not running at ${base}. ${START_ADVICE}`,
+ };
+ }
+ const edition = typeof health.edition === "string" ? health.edition : undefined;
+ if (!edition || !/azure/i.test(edition)) {
+ return {
+ ok: false,
+ problem: "wrong-edition",
+ edition,
+ message: `The emulator at ${base} is not the LocalStack Azure emulator (edition: ${edition ?? "unknown"}). ${START_ADVICE}`,
+ };
+ }
+ if (health.license === false) {
+ return {
+ ok: false,
+ problem: "license",
+ edition,
+ message: `The LocalStack Azure emulator at ${base} reports no active license. Start it with a LOCALSTACK_AUTH_TOKEN whose license includes LocalStack for Azure.`,
+ };
+ }
+
+ const info = (await deps.getJson(`${base}/_localstack/info`, HEALTH_TIMEOUT_MS)) as
+ { session_id?: unknown; version?: unknown } | undefined;
+ const sessionId = typeof info?.session_id === "string" ? info.session_id : undefined;
+ const version = typeof info?.version === "string" ? info.version : undefined;
+ const fields = { edition, sessionId, version };
+
+ // Once per emulator session: HTTPS comes up a few seconds after plain HTTP.
+ if (sessionId && sessionId === verifiedSession) return { ok: true, ...fields };
+ const url = new URL(config.endpoint);
+ const host = url.hostname;
+ const port = Number(url.port) || 443;
+ // The gateway LOCALSTACK_HOSTNAME names (host.docker.internal in Docker); localhost as 127.0.0.1.
+ const gateway = new URL(base).hostname.replace(/^\[|\]$/g, "");
+ const address = gateway === "localhost" ? "127.0.0.1" : gateway;
+ const waitMs = opts.httpsWaitMs ?? HTTPS_READY_WAIT_MS;
+ const deadline = deps.now() + waitMs;
+ let ready = await deps.httpsReady(address, port, host, HEALTH_TIMEOUT_MS);
+ while (!ready && deps.now() < deadline) {
+ await deps.sleep(HTTPS_RETRY_MS);
+ ready = await deps.httpsReady(address, port, host, HEALTH_TIMEOUT_MS);
+ }
+ if (!ready) {
+ const notReady =
+ waitMs > 0
+ ? `did not become ready within ${Math.round(waitMs / 1000)} s`
+ : "is not ready yet";
+ return {
+ ok: false,
+ problem: "https-not-ready",
+ ...fields,
+ message: `The LocalStack Azure emulator answers on ${base}, but its HTTPS endpoint ${config.endpoint} ${notReady}. It may still be starting; retry in a few seconds.`,
+ };
+ }
+ // az resolves the endpoint's name itself.
+ const addresses = await deps.lookup(host).catch(() => []);
+ if (!addresses.length || !addresses.every(isLoopback)) {
+ return {
+ ok: false,
+ problem: "dns",
+ ...fields,
+ message: `${host} ${addresses.length ? `resolved to ${addresses.join(", ")}` : "did not resolve"}, not to 127.0.0.1. It is a public DNS name for 127.0.0.1, which some routers and corporate resolvers block (DNS-rebinding protection): allow localhost.localstack.cloud there, or add \`127.0.0.1 ${host}\` to your hosts file.`,
+ };
+ }
+ if (sessionId) verifiedSession = sessionId;
+ return { ok: true, ...fields };
+}
diff --git a/src/lib/azure/output.test.ts b/src/lib/azure/output.test.ts
new file mode 100644
index 0000000..536ad63
--- /dev/null
+++ b/src/lib/azure/output.test.ts
@@ -0,0 +1,105 @@
+import { formatAzResult, MAX_OUTPUT_CHARS, prepareStderr } from "./output";
+import type { AzRunResult } from "./runner";
+
+const result = (over: Partial): AzRunResult => ({
+ exitCode: 0,
+ stdout: "",
+ stderr: "",
+ timedOut: false,
+ aborted: false,
+ truncated: false,
+ ...over,
+});
+const opts = { notes: [], timeoutSeconds: 300, healthBaseUrl: "http://localhost:4566" };
+const text = (r: { content: Array<{ text: string }> }) => r.content[0].text;
+
+describe("formatAzResult", () => {
+ test("success: az's output, the policy's notes first, warnings after", () => {
+ const answer = formatAzResult(
+ result({ stdout: '[\r\n "rg"\r\n]\r\n', stderr: "WARNING: x\n" }),
+ {
+ ...opts,
+ notes: ["Rewrote the URL."],
+ }
+ );
+ expect(text(answer)).toBe('Rewrote the URL.\n\n[\n "rg"\n]\n\nWARNING: x');
+ expect(text(formatAzResult(result({}), opts))).toBe(
+ "The command succeeded and printed no output."
+ );
+ });
+
+ test("failure: the exit code in the title, az's error, a traceback reduced to its ERROR line", () => {
+ const stderr =
+ 'Traceback (most recent call last):\n File "x.py", line 1\nKeyError: 1\nERROR: The command failed.\n';
+ const answer = text(formatAzResult(result({ exitCode: 1, stderr }), opts));
+ expect(answer).toBe(
+ "ā **Command Failed (exit 1)**\n\n(Python traceback omitted)\nERROR: The command failed."
+ );
+ });
+
+ test("an operation the emulator does not implement links its coverage list", () => {
+ const stderr =
+ "ERROR: (NotImplemented) The API operation 'GET /subscriptions/x/providers/Microsoft.Foo/bars' is not yet implemented in LocalStack.";
+ const answer = text(formatAzResult(result({ exitCode: 1, stderr }), opts));
+ expect(answer).toContain(
+ "does not implement `GET /subscriptions/x/providers/Microsoft.Foo/bars` yet"
+ );
+ expect(answer).toContain("http://localhost:4566/_localstack/coverage");
+ });
+
+ test("an unknown command group points at the user's own az extension add", () => {
+ const stderr = "ERROR: 'graph' is misspelled or not recognized by the system.";
+ const answer = text(formatAzResult(result({ exitCode: 2, stderr }), opts));
+ expect(answer).toContain("If `graph` comes from an Azure CLI extension");
+ expect(answer).toContain("az extension add --name ");
+ });
+
+ test("a confirmation prompt asks for --yes; a missing bicep points at PATH", () => {
+ const hint = (stderr: string) => text(formatAzResult(result({ exitCode: 1, stderr }), opts));
+ expect(
+ hint("ERROR: Unable to prompt for confirmation as no tty available. Use --yes.")
+ ).toContain("Re-run it with `--yes`.");
+ expect(hint('ERROR: Could not find the "bicep" executable on PATH.')).toContain(
+ "The tool runs Bicep from PATH."
+ );
+ });
+
+ test("timeout, cancel and a spawn failure", () => {
+ expect(text(formatAzResult(result({ exitCode: null, timedOut: true }), opts))).toMatch(
+ /^ā \*\*Command Timed Out\*\*\n\n`az` did not finish within 300 s/
+ );
+ expect(text(formatAzResult(result({ aborted: true }), opts))).toContain("Command Cancelled");
+ expect(text(formatAzResult(result({ spawnError: "ENOENT: x" }), opts))).toContain("ENOENT: x");
+ });
+
+ test("the capture cap: az was stopped, so the answer has its own title and the output so far", () => {
+ // What the runner returns at the cap: the killed az has no exit code.
+ const big = text(
+ formatAzResult(
+ result({ exitCode: null, stdout: "y".repeat(MAX_OUTPUT_CHARS + 10), truncated: true }),
+ opts
+ )
+ );
+ expect(big).toMatch(/^ā \*\*Output Too Large\*\*\n\n`az` printed more than the tool captures/);
+ expect(big).toContain("[... 10 more characters of output cut]");
+ expect(big).not.toContain("exit null");
+ expect(big).not.toContain("exited without an error message");
+ });
+});
+
+describe("prepareStderr", () => {
+ test("keeps the end, where az prints its ERROR: line after a long --debug log", () => {
+ const debug = Array.from({ length: 200 }, (_, i) => `DEBUG: cli.knack: line ${i}`).join("\n");
+ const stderr = `${debug}\nERROR: Please run 'az login' to setup account.\nINFO: telemetry: done`;
+ const prepared = prepareStderr(stderr);
+ expect(prepared).toMatch(/^\[\.\.\. [\d,]+ earlier characters of stderr cut\]\n/);
+ expect(prepared).toContain("ERROR: Please run 'az login' to setup account.");
+ expect(prepared.endsWith("INFO: telemetry: done")).toBe(true);
+ });
+
+ test("caps without splitting a surrogate pair", () => {
+ const capped = prepareStderr("a".repeat(100) + "š" + "b".repeat(3_999));
+ expect(capped.endsWith("b".repeat(3_999))).toBe(true);
+ expect(capped).not.toMatch(/[\udc00-\udfff]b/);
+ });
+});
diff --git a/src/lib/azure/output.ts b/src/lib/azure/output.ts
new file mode 100644
index 0000000..519a25b
--- /dev/null
+++ b/src/lib/azure/output.ts
@@ -0,0 +1,136 @@
+import { ResponseBuilder } from "../../core/response-builder";
+import type { AzRunResult } from "./runner";
+
+/** How much of az's output a response carries, in characters. */
+export const MAX_OUTPUT_CHARS = 30_000;
+export const MAX_STDERR_CHARS = 4_000;
+
+type ToolResponse = ReturnType;
+
+export interface FormatOptions {
+ /** The policy's notes, such as a rewritten URL. */
+ notes: string[];
+ timeoutSeconds: number;
+ /** The emulator's gateway, for the coverage link. */
+ healthBaseUrl: string;
+}
+
+/** The first `max` characters, never ending inside a surrogate pair, with a note on the cut. */
+function cap(text: string, max: number, what: string): string {
+ if (text.length <= max) return text;
+ const end = /[\ud800-\udbff]/.test(text[max - 1]) ? max - 1 : max;
+ return `${text.slice(0, end)}\n[... ${(text.length - end).toLocaleString("en-US")} more characters of ${what} cut]`;
+}
+
+/** The last `max` characters, never starting inside a surrogate pair, with a note on the cut. */
+function capTail(text: string, max: number, what: string): string {
+ if (text.length <= max) return text;
+ const start = text.length - max + (/[\udc00-\udfff]/.test(text[text.length - max]) ? 1 : 0);
+ return `[... ${start.toLocaleString("en-US")} earlier characters of ${what} cut]\n${text.slice(start)}`;
+}
+
+/**
+ * az's stderr without Python traceback frames (its ERROR: lines stay), capped from the front: az
+ * prints its ERROR: line last, after any `--debug` log.
+ */
+export function prepareStderr(raw: string): string {
+ const kept: string[] = [];
+ let inTraceback = false;
+ for (const line of raw.replace(/\r\n/g, "\n").split("\n")) {
+ if (/^Traceback \(most recent call last\):\s*$/.test(line)) {
+ if (!inTraceback) kept.push("(Python traceback omitted)");
+ inTraceback = true;
+ } else if (!inTraceback || line.startsWith("ERROR:")) {
+ kept.push(line);
+ }
+ }
+ return capTail(kept.join("\n").trim(), MAX_STDERR_CHARS, "stderr");
+}
+
+function failureHint(stderr: string, opts: FormatOptions): string | undefined {
+ const notImplemented =
+ /The API operation '([A-Z]+) ([^']+)' is not yet implemented in LocalStack\./.exec(stderr);
+ if (notImplemented || /\(NotImplemented\)|"code": "NotImplemented"/.test(stderr)) {
+ const what = notImplemented
+ ? `\`${notImplemented[1]} ${notImplemented[2]}\``
+ : "this operation";
+ return (
+ `The LocalStack Azure emulator does not implement ${what} yet, so retrying will not help. ` +
+ `The implemented operations are listed at ${opts.healthBaseUrl}/_localstack/coverage.`
+ );
+ }
+ const unknown = /'([\w-]+)' is misspelled or not recognized by the system\./.exec(stderr);
+ if (unknown) {
+ return (
+ `If \`${unknown[1]}\` comes from an Azure CLI extension, add the extension with your own ` +
+ "Azure CLI (`az extension list-available` lists them, `az extension add --name ` " +
+ "adds one): the tool loads the extensions in your extension directory. Meanwhile `rest` " +
+ "with a relative URL usually works."
+ );
+ }
+ if (/Unable to prompt for confirmation as no tty available\. Use --yes\./.test(stderr)) {
+ return "This command asks for confirmation. Re-run it with `--yes`.";
+ }
+ if (/Could not find the "bicep" executable on PATH/.test(stderr)) {
+ // az's own advice (config set, az bicep install) is refused here.
+ return "The tool runs Bicep from PATH. Install it (for example `brew install bicep` or `winget install Microsoft.Bicep`), then restart this MCP server.";
+ }
+ return undefined;
+}
+
+export function formatAzResult(result: AzRunResult, opts: FormatOptions): ToolResponse {
+ if (result.spawnError) {
+ return ResponseBuilder.error("Azure CLI Could Not Start", result.spawnError);
+ }
+ if (result.aborted) {
+ return ResponseBuilder.error(
+ "Command Cancelled",
+ "The client cancelled the call, so `az` was stopped."
+ );
+ }
+ const stderr = prepareStderr(result.stderr);
+ if (result.timedOut) {
+ const advice =
+ `\`az\` did not finish within ${opts.timeoutSeconds} s and was stopped; it may have changed ` +
+ "the emulator already. For long operations raise LOCALSTACK_AZ_TIMEOUT_SECONDS, or use " +
+ "`--no-wait` where the command has it.";
+ return ResponseBuilder.error(
+ "Command Timed Out",
+ [advice, stderr].filter(Boolean).join("\n\n")
+ );
+ }
+ const stdout = cap(
+ result.stdout.replace(/\r\n/g, "\n").replace(/\s+$/, ""),
+ MAX_OUTPUT_CHARS,
+ "output"
+ );
+ if (result.truncated) {
+ const advice =
+ "`az` printed more than the tool captures, so it was stopped and its output is " +
+ "incomplete; it may have changed the emulator already. Narrow it with `--query` or `-o tsv`.";
+ return ResponseBuilder.error(
+ "Output Too Large",
+ [advice, stdout && `Output so far:\n${stdout}`, stderr, ...opts.notes]
+ .filter(Boolean)
+ .join("\n\n")
+ );
+ }
+ if (result.exitCode === 0) {
+ const blocks = [
+ ...opts.notes,
+ stdout || "The command succeeded and printed no output.",
+ stderr,
+ ];
+ return ResponseBuilder.markdown(blocks.filter(Boolean).join("\n\n"));
+ }
+ const blocks = [
+ stderr || "`az` exited without an error message.",
+ stdout && `Output:\n${stdout}`,
+ failureHint(result.stderr, opts),
+ ...opts.notes,
+ ];
+ return ResponseBuilder.error(
+ `Command Failed (exit ${result.exitCode})`,
+ blocks.filter(Boolean).join("\n\n")
+ );
+}
diff --git a/src/lib/azure/policy.test.ts b/src/lib/azure/policy.test.ts
new file mode 100644
index 0000000..bc06230
--- /dev/null
+++ b/src/lib/azure/policy.test.ts
@@ -0,0 +1,176 @@
+import { AzSyntaxError, splitAzArgs } from "./argv";
+import { evaluateAzCommand } from "./policy";
+
+const allowed = (command: string) => {
+ const result = evaluateAzCommand(command);
+ if (!result.ok) throw new Error(`refused: ${result.message}`);
+ return result;
+};
+const refusal = (command: string) => {
+ const result = evaluateAzCommand(command);
+ if (result.ok) throw new Error(`allowed: ${command}`);
+ return result;
+};
+
+describe("splitAzArgs", () => {
+ test("quotes, empty arguments, data inside quotes and literal backslashes", () => {
+ expect(
+ splitAzArgs(`x -n "my group" --tags 'a=b c' --v "" --q "\`a\`" C:\\tmp\\a $NAME`)
+ ).toEqual([
+ "x",
+ "-n",
+ "my group",
+ "--tags",
+ "a=b c",
+ "--v",
+ "",
+ "--q",
+ "`a`",
+ "C:\\tmp\\a",
+ "$NAME",
+ ]);
+ expect(splitAzArgs('x "\\$HOME \\` \\" \\\\" "a\\\nb"')).toEqual(["x", '$HOME ` " \\', "ab"]);
+ });
+
+ test.each(["a; b", "a && b", "a | b", "a > f", "a $(b)", "a ${b}", "a `b`", "a\nb", 'a "open'])(
+ "refuses %j",
+ (command) => expect(() => splitAzArgs(command)).toThrow(AzSyntaxError)
+ );
+});
+
+describe("evaluateAzCommand", () => {
+ test("strips one leading az and passes the argv through", () => {
+ expect(allowed("az group list -o table")).toEqual({
+ ok: true,
+ argv: ["group", "list", "-o", "table"],
+ notes: [],
+ });
+ expect(allowed("--help").argv).toEqual(["--help"]);
+ });
+
+ test.each([
+ ["", "No command given"],
+ ["az az group list", "Extra executable"],
+ ["azlocal group list", "Extra executable"],
+ ["--debug group list", "Not a valid command"],
+ ["group list; rm -rf /", "Command not understood"],
+ ])("refuses %j (%s)", (command, title) => {
+ expect(refusal(command).title).toContain(title);
+ });
+
+ test("refuses --version, which asks PyPI for updates, and allows the offline version", () => {
+ expect(refusal("--version").message).toContain("use `version`");
+ expect(allowed("version").argv).toEqual(["version"]);
+ });
+
+ test.each([
+ "login",
+ "logout",
+ "account clear",
+ "cloud set --name AzureCloud",
+ "config set core.output=table",
+ "extension add --name fleet",
+ "upgrade",
+ "bicep install",
+ "aks install-cli",
+ "storage copy -s a -d b",
+ "storage fs directory upload -f fs -s ./dir --account-name a",
+ "storage fs directory download -f fs -s dir -d ./out --account-name a",
+ "find vm",
+ "webapp ssh -n app -g rg",
+ "ssh vm -n vm1 -g rg",
+ ])("denies %j", (command) => {
+ expect(refusal(command).message).toMatch(/is not allowed here: /);
+ });
+
+ test("allows config get, and commands that only share a word with a denied one", () => {
+ expect(allowed("config get core.output").argv[0]).toBe("config");
+ expect(allowed("cloud show").argv).toEqual(["cloud", "show"]);
+ expect(allowed("extension list").argv).toEqual(["extension", "list"]);
+ });
+
+ test("denied flags, matched exactly", () => {
+ expect(refusal("container logs -n c -g rg --follow").title).toBe("Flag not allowed");
+ expect(refusal("webapp up -n app -b").message).toContain(
+ "`az webapp up` cannot be run with `-b`"
+ );
+ expect(allowed("group list -b x").argv).toContain("-b"); // -b is denied only on webapp up
+ // az reads these as --location and --force-deletion, not as the denied flags they start.
+ expect(allowed("group create -n rg --lo westeurope").argv).toContain("--lo");
+ expect(allowed("vm delete -n vm1 -g rg --fo --yes").argv).toContain("--fo");
+ });
+
+ test.each([
+ "storage blob upload -f ../secret.txt -c c -n n",
+ "storage blob upload -f../secret.txt -c c -n n",
+ "storage blob upload -f..\\secret.txt -c c -n n",
+ "deployment group create -g rg --template-file=..\\up\\main.json",
+ "group create -n x --tags @../tags.json",
+ "rest --url /a/../b",
+ ])("refuses path traversal: %j", (command) => {
+ expect(refusal(command).message).toContain("path traversal");
+ });
+
+ test("leaves dots that are not a path segment alone", () => {
+ expect(allowed("group list --query \"[?name=='a..b']\"").argv).toHaveLength(4);
+ expect(allowed("storage blob upload -f ./data/hello.txt").argv).toContain("./data/hello.txt");
+ });
+
+ test("rewrites a management.azure.com URL given to rest into a relative path", () => {
+ const rest = allowed("rest --url https://management.azure.com/subscriptions?api-version=1");
+ expect(rest.argv).toEqual(["rest", "--url", "/subscriptions?api-version=1"]);
+ expect(rest.notes[0]).toContain("relative path `/subscriptions?api-version=1`");
+ expect(allowed("rest --uri=HTTPS://Management.Azure.Com:443/x").argv[1]).toBe("--uri=/x");
+ expect(allowed("rest -u https://management.azure.com").argv[2]).toBe("/");
+ });
+
+ test("rest calls local hosts only: a file given as the body would go wherever the URL points", () => {
+ for (const command of [
+ "rest --method post --url http://10.1.2.3:8080/x --body @/etc/hosts",
+ "rest --uri=HTTP://evil.example/x",
+ "rest -uhttps://evil.example/x",
+ "rest --ur https://evil.example/x", // az finds --ur ambiguous; refused all the same
+ "rest --url http:evil.example/x",
+ "rest --url https://management.azure.com.evil.example/x",
+ "rest --url https://localhost@evil.example/x",
+ ]) {
+ expect(refusal(command).title).toBe("URL not allowed");
+ }
+ for (const url of [
+ "https://azure.localhost.localstack.cloud:4566/x",
+ "http://localhost:4566/_localstack/health",
+ "http://127.0.0.1:4566/x",
+ "/subscriptions?api-version=2022-12-01",
+ "//evil.example/x", // no scheme: az puts the emulator's endpoint before it
+ ]) {
+ expect(allowed(`rest --url ${url}`).argv[2]).toBe(url);
+ }
+ expect(allowed("group list --query https://management.azure.com/x").notes).toEqual([]);
+ });
+
+ test("a leading ~ is the user's home, not the tool's private one", () => {
+ const linux = { home: "/home/u", platform: "linux" as NodeJS.Platform };
+ const argv = (command: string, user = linux) => {
+ const result = evaluateAzCommand(command, user);
+ if (!result.ok) throw new Error(result.message);
+ return result.argv;
+ };
+ expect(argv("deployment group create -g rg --template-file ~/infra/main.json")[6]).toBe(
+ "/home/u/infra/main.json"
+ );
+ expect(argv("rest --url /x --body @~/b.json")[4]).toBe("@/home/u/b.json");
+ expect(argv("bicep build --file=~/main.bicep")[2]).toBe("--file=/home/u/main.bicep");
+ expect(argv("storage blob upload -f~/a.txt -c c")[3]).toBe("-f/home/u/a.txt");
+ expect(argv("storage blob upload -f=~/a.txt -c c")[3]).toBe("-f=/home/u/a.txt");
+ expect(argv("group list --query ~x --tags a=~/b")).toEqual([
+ "group",
+ "list",
+ "--query",
+ "~x",
+ "--tags",
+ "a=~/b",
+ ]);
+ const windows = { home: "C:\\Users\\u", platform: "win32" as const };
+ expect(argv("storage blob upload -f ~\\a.txt", windows)[4]).toBe("C:\\Users\\u\\a.txt");
+ });
+});
diff --git a/src/lib/azure/policy.ts b/src/lib/azure/policy.ts
new file mode 100644
index 0000000..cc3340e
--- /dev/null
+++ b/src/lib/azure/policy.ts
@@ -0,0 +1,219 @@
+import os from "os";
+import { AzSyntaxError, splitAzArgs } from "./argv";
+import { isLocalHost } from "./config";
+
+/**
+ * The Azure command policy: a decision over one `az` command string, made before anything runs.
+ * It refuses commands that would break the tool's own profile, change this machine or never end
+ * (the deny list), refuses `..` path traversal as the AWS client does, and keeps `rest` on local
+ * hosts: a management.azure.com URL becomes the relative path the emulator serves, and any other
+ * absolute URL must name a local host. A leading `~` becomes the user's home, which is what `az`
+ * would make of it without the tool's private HOME. Everything else is left to `az`.
+ */
+
+export type PolicyResult =
+ { ok: true; argv: string[]; notes: string[] } | { ok: false; title: string; message: string };
+
+const deny = (reason: string, ...prefixes: string[][]) =>
+ prefixes.map((match) => ({ match, reason }));
+
+/** Denied command groups and verbs, matched on the leading command words. */
+export const DENIED: Array<{ match: string[]; reason: string }> = [
+ ...deny("the CLI is already logged in to the emulator with a dummy account", ["login"]),
+ ...deny(
+ "this would break routing to the emulator",
+ ["logout"],
+ ["account", "clear"],
+ ["cloud", "register"],
+ ["cloud", "unregister"],
+ ["cloud", "update"],
+ ["cloud", "set"]
+ ),
+ // `config get` survives this (ALLOWED_EXCEPTIONS); every other `config` rewrites the profile.
+ ...deny("the tool manages the CLI configuration", ["config"], ["configure"], ["init"]),
+ ...deny(
+ "the tool uses the extensions you installed with your own `az extension add`",
+ ["extension", "add"],
+ ["extension", "update"],
+ ["extension", "remove"]
+ ),
+ ...deny(
+ "this downloads or installs software on this machine",
+ ["upgrade"],
+ ["bicep", "install"],
+ ["bicep", "upgrade"],
+ ["bicep", "uninstall"],
+ ["aks", "install-cli"], // on Windows it also rewrites the user's PATH with `setx`
+ ["storage", "copy"], // these download azcopy first
+ ["storage", "remove"],
+ ["storage", "blob", "sync"],
+ ["storage", "fs", "directory", "upload"],
+ ["storage", "fs", "directory", "download"]
+ ),
+ ...deny("this calls a Microsoft web service", ["find"], ["feedback"], ["survey"]),
+ ...deny("this needs an interactive session", ["interactive"], ["self-test"]),
+ ...deny(
+ "this opens a browser, a shell, a tunnel or a live stream on this machine",
+ ["aks", "browse"],
+ ["webapp", "browse"],
+ ["containerapp", "browse"],
+ ["webapp", "ssh"],
+ ["webapp", "create-remote-connection"],
+ ["webapp", "log", "tail"],
+ ["container", "exec"],
+ ["container", "attach"],
+ ["containerapp", "exec"],
+ ["network", "bastion", "ssh"],
+ ["network", "bastion", "rdp"],
+ ["network", "bastion", "tunnel"],
+ ["ssh"] // the ssh extension's `ssh vm` and `ssh arc`
+ ),
+];
+
+const ALLOWED_EXCEPTIONS = [["config", "get"]];
+
+/** Flags that are refused, everywhere or on one command. */
+export const DENIED_FLAGS: Array<{ command?: string[]; flag: string; reason: string }> = [
+ { flag: "--follow", reason: "it streams output until killed" },
+ { flag: "--login-with-github", reason: "it opens a browser login" },
+ { command: ["webapp", "up"], flag: "--launch-browser", reason: "it opens a browser" },
+ { command: ["webapp", "up"], flag: "-b", reason: "it opens a browser" },
+ { command: ["webapp", "up"], flag: "--logs", reason: "it streams logs until killed" },
+];
+
+const START_TOKEN = /^[a-z][a-z0-9-]*$/;
+/** A `..` path segment in a value, an `@file` or a `--flag=value`, as the AWS client refuses it. */
+const TRAVERSAL = /(^|[\\/=@])\.\.([\\/]|$)/;
+const REST_URL_FLAGS = ["--url", "--uri", "-u"];
+const MANAGEMENT_URL = /^https?:\/\/management\.azure\.com(:443)?(?=[/?#]|$)/i;
+
+const hasPrefix = (argv: string[], prefix: string[]) =>
+ prefix.length <= argv.length && prefix.every((word, index) => argv[index] === word);
+
+/** A token's flag and the value packed into it (`--flag=value`, `-fvalue`), if any. */
+function parseOption(token: string): { flag: string; value?: string } {
+ if (token.startsWith("--")) {
+ const eq = token.indexOf("=");
+ return eq < 0 ? { flag: token } : { flag: token.slice(0, eq), value: token.slice(eq + 1) };
+ }
+ const rest = token.slice(2);
+ return { flag: token.slice(0, 2), value: rest ? rest.replace(/^=/, "") : undefined };
+}
+
+/** A `rest` URL flag, or a prefix of one (`--ur`): az finds those ambiguous, and so are they here. */
+const isRestUrlFlag = (flag: string) =>
+ REST_URL_FLAGS.some(
+ (full) => flag === full || (flag.startsWith("--") && flag.length >= 4 && full.startsWith(flag))
+ );
+
+/** `az rest` sends a URL with a scheme as it is, and puts the emulator's endpoint before any other. */
+const hasScheme = (url: string) => url.includes("://") || /^https?:/i.test(url);
+
+/**
+ * `~`, `~/x`, `@~/x`, `--flag=~/x` and `-f~/x` with the user's home in place of the `~`, in any
+ * argument: the policy cannot tell which values are paths.
+ */
+function expandHome(token: string, home: string, platform: NodeJS.Platform): string {
+ const tilde =
+ platform === "win32"
+ ? /^(--[^=]+=|-[A-Za-z]=?)?(@?)~(?=$|[\\/])/
+ : /^(--[^=]+=|-[A-Za-z]=?)?(@?)~(?=$|\/)/;
+ return token.replace(tilde, (_match, flag = "", at = "") => `${flag}${at}${home}`);
+}
+
+const refuse = (title: string, message: string): PolicyResult => ({ ok: false, title, message });
+
+export function evaluateAzCommand(
+ command: string,
+ user: { home?: string; platform?: NodeJS.Platform } = {}
+): PolicyResult {
+ // One leading `az` is optional.
+ const body = command.trim().replace(/^az(\s+|$)/, "");
+
+ let argv: string[];
+ try {
+ argv = splitAzArgs(body);
+ } catch (error) {
+ const message = error instanceof AzSyntaxError ? error.message : "Command could not be parsed.";
+ return refuse("Command not understood", message);
+ }
+ if (argv.length === 0) {
+ return refuse("No command given", "Give one `az` command, for example `group list`.");
+ }
+ if (argv[0] === "az" || argv[0] === "azlocal") {
+ return refuse(
+ "Extra executable in the command",
+ "Give the command without the `az` or `azlocal` executable, for example `group list`."
+ );
+ }
+ if (argv[0] === "--version") {
+ return refuse(
+ "Not a valid command",
+ "`--version` asks PyPI for a newer Azure CLI; use `version`, which makes no network call."
+ );
+ }
+ if (!START_TOKEN.test(argv[0]) && !["--help", "-h"].includes(argv[0])) {
+ return refuse(
+ "Not a valid command",
+ "Start with a command group or verb, for example `group list`."
+ );
+ }
+ // A short option's attached value (`-f../x`) is checked on its own too.
+ const values = argv.flatMap((t) => (/^-[^-]/.test(t) ? [t, parseOption(t).value ?? ""] : [t]));
+ if (values.some((value) => TRAVERSAL.test(value))) {
+ return refuse("Path not allowed", "Command contains forbidden path traversal (`..`).");
+ }
+
+ const isException = ALLOWED_EXCEPTIONS.some((prefix) => hasPrefix(argv, prefix));
+ for (const entry of DENIED) {
+ if (!hasPrefix(argv, entry.match)) continue;
+ if (isException && entry.match.join(" ") === "config") continue;
+ return refuse(
+ "Command not allowed",
+ `\`az ${entry.match.join(" ")}\` is not allowed here: ${entry.reason}.`
+ );
+ }
+ const flags = argv.filter((token) => token.startsWith("-")).map((t) => parseOption(t).flag);
+ for (const rule of DENIED_FLAGS) {
+ if (rule.command && !hasPrefix(argv, rule.command)) continue;
+ if (flags.includes(rule.flag)) {
+ const scope = rule.command ? `\`az ${rule.command.join(" ")}\` ` : "";
+ return refuse(
+ "Flag not allowed",
+ `${scope}cannot be run with \`${rule.flag}\` here: ${rule.reason}.`
+ );
+ }
+ }
+
+ // `rest` would send an absolute URL, and a file given as its body, to any host.
+ const notes: string[] = [];
+ if (argv[0] === "rest") {
+ for (let i = 1; i < argv.length; i++) {
+ if (!argv[i].startsWith("-")) continue;
+ const { flag, value } = parseOption(argv[i]);
+ if (!isRestUrlFlag(flag)) continue;
+ const index = value === undefined ? i + 1 : i;
+ const url = value ?? argv[index];
+ if (url === undefined) continue;
+ if (MANAGEMENT_URL.test(url)) {
+ const relative = url.replace(MANAGEMENT_URL, "").replace(/^(?!\/)/, "/");
+ argv[index] =
+ value === undefined ? relative : `${argv[i].slice(0, -value.length)}${relative}`;
+ notes.push(
+ `Rewrote the management.azure.com URL to the relative path \`${relative}\`, which the emulator serves.`
+ );
+ continue;
+ }
+ const host = URL.canParse(url) ? new URL(url).hostname : undefined;
+ if (hasScheme(url) && !(host && isLocalHost(host))) {
+ return refuse(
+ "URL not allowed",
+ `\`rest\` calls the emulator only: give a relative URL such as \`/subscriptions?api-version=2022-12-01\`, or one on a local host. ${host ? `"${host}" is not a local host.` : `"${url}" is not a valid URL.`}`
+ );
+ }
+ }
+ }
+ const home = user.home ?? os.homedir();
+ const platform = user.platform ?? process.platform;
+ return { ok: true, argv: argv.map((token) => expandHome(token, home, platform)), notes };
+}
diff --git a/src/lib/azure/preflight.test.ts b/src/lib/azure/preflight.test.ts
new file mode 100644
index 0000000..c6990e8
--- /dev/null
+++ b/src/lib/azure/preflight.test.ts
@@ -0,0 +1,107 @@
+import { isRunningInDocker } from "../localstack/localstack.utils";
+import { BootstrapError } from "./bootstrap";
+import { getAzureEmulatorStatus } from "./emulator";
+import {
+ requireAzureCli,
+ requireAzureCliConfigured,
+ requireAzureConfig,
+ requireAzureEmulatorRunning,
+} from "./preflight";
+import { AzResolveError } from "./resolve-az";
+import type { AzRunResult } from "./runner";
+import {
+ AzCancelledError,
+ azCli,
+ azureConfig,
+ ensureProfile,
+ recordEmulatorSession,
+} from "./services";
+
+jest.mock("../localstack/localstack.utils", () => ({ isRunningInDocker: jest.fn(() => false) }));
+jest.mock("./emulator", () => ({ getAzureEmulatorStatus: jest.fn() }));
+jest.mock("./services", () => ({
+ ...jest.requireActual("./services"),
+ azCli: jest.fn(),
+ azureConfig: jest.fn(() => ({ errors: [] })),
+ ensureProfile: jest.fn(),
+ recordEmulatorSession: jest.fn(),
+}));
+
+const text = (r: { content: Array<{ text?: string }> } | null) => r?.content[0].text ?? "";
+const failed = (over: Partial): AzRunResult => ({
+ exitCode: 1,
+ stdout: "",
+ stderr: "",
+ timedOut: false,
+ aborted: false,
+ truncated: false,
+ ...over,
+});
+
+beforeEach(() => jest.clearAllMocks());
+
+test("requireAzureConfig lists every setting the tool cannot run with", () => {
+ expect(requireAzureConfig()).toBeNull();
+ jest.mocked(azureConfig).mockReturnValueOnce({ errors: ["A is bad.", "B is bad."] } as never);
+ expect(text(requireAzureConfig())).toContain("- A is bad.\n- B is bad.");
+});
+
+test("requireAzureEmulatorRunning records the session and passes the emulator's explanation on", async () => {
+ jest.mocked(getAzureEmulatorStatus).mockResolvedValueOnce({ ok: true, sessionId: "s1" });
+ expect(await requireAzureEmulatorRunning()).toBeNull();
+ expect(recordEmulatorSession).toHaveBeenCalledWith("s1");
+ jest
+ .mocked(getAzureEmulatorStatus)
+ .mockResolvedValueOnce({ ok: false, problem: "not-running", message: "Start it." });
+ const answer = text(await requireAzureEmulatorRunning());
+ expect(answer).toContain("LocalStack Azure Emulator Not Ready");
+ expect(answer).toContain("Start it.");
+});
+
+describe("requireAzureCli", () => {
+ test("an az that runs: no answer, the call goes on", async () => {
+ jest.mocked(azCli).mockResolvedValueOnce({ file: "/usr/bin/az", prefixArgs: [] });
+ expect(await requireAzureCli()).toBeNull();
+ });
+
+ test("a missing az: the install advice and what was skipped; in Docker, the image's limit", async () => {
+ const missing = new AzResolveError("The Azure CLI (az) was not found.", ["/mnt/c/az: Windows"]);
+ jest.mocked(azCli).mockRejectedValue(missing);
+ const answer = text(await requireAzureCli());
+ expect(answer).toContain("Azure CLI Not Available");
+ expect(answer).toContain("Skipped:\n- /mnt/c/az: Windows");
+ jest.mocked(isRunningInDocker).mockReturnValueOnce(true);
+ expect(text(await requireAzureCli())).toContain("Docker image does not include the Azure CLI");
+ });
+
+ test("passes the signal on; a cancelled check is a cancelled call", async () => {
+ const signal = new AbortController().signal;
+ jest.mocked(azCli).mockRejectedValueOnce(new AzCancelledError());
+ expect(text(await requireAzureCli(signal))).toContain("Command Cancelled");
+ expect(azCli).toHaveBeenCalledWith(signal);
+ });
+});
+
+describe("requireAzureCliConfigured", () => {
+ test("a profile that is set up: no answer, the call goes on", async () => {
+ jest.mocked(ensureProfile).mockResolvedValueOnce(undefined);
+ expect(await requireAzureCliConfigured()).toBeNull();
+ });
+
+ test("a failed step names it and shows az's error", async () => {
+ const error = new BootstrapError("login", failed({ stderr: "ERROR: AADSTS7000215" }));
+ jest.mocked(ensureProfile).mockRejectedValueOnce(error);
+ const answer = text(await requireAzureCliConfigured());
+ expect(answer).toContain("Azure CLI Profile Setup Failed");
+ expect(answer).toContain("failed at `az login`");
+ expect(answer).toContain("ERROR: AADSTS7000215");
+ });
+
+ test("a cancelled setup is a cancelled call", async () => {
+ const error = new BootstrapError("cloud register", failed({ exitCode: null, aborted: true }));
+ jest.mocked(ensureProfile).mockRejectedValueOnce(error);
+ const signal = new AbortController().signal;
+ expect(text(await requireAzureCliConfigured(signal))).toContain("Command Cancelled");
+ expect(ensureProfile).toHaveBeenCalledWith(signal);
+ });
+});
diff --git a/src/lib/azure/preflight.ts b/src/lib/azure/preflight.ts
new file mode 100644
index 0000000..2a3c79e
--- /dev/null
+++ b/src/lib/azure/preflight.ts
@@ -0,0 +1,78 @@
+import { ResponseBuilder } from "../../core/response-builder";
+import { isRunningInDocker } from "../localstack/localstack.utils";
+import { BootstrapError } from "./bootstrap";
+import { getAzureEmulatorStatus } from "./emulator";
+import { prepareStderr } from "./output";
+import { AzResolveError } from "./resolve-az";
+import {
+ AzCancelledError,
+ azCli,
+ azureConfig,
+ ensureProfile,
+ recordEmulatorSession,
+} from "./services";
+
+type ToolResponse = ReturnType;
+
+const cancelled = () =>
+ ResponseBuilder.error("Command Cancelled", "The client cancelled the call, so `az` was stopped.");
+
+/** Settings the tool cannot run with: a config dir that overlaps ~/.azure, a bad timeout, ... */
+export function requireAzureConfig(): ToolResponse | null {
+ const { errors } = azureConfig();
+ if (errors.length === 0) return null;
+ return ResponseBuilder.error(
+ "Azure Tool Configuration Error",
+ errors.map((error) => `- ${error}`).join("\n")
+ );
+}
+
+export async function requireAzureEmulatorRunning(): Promise {
+ const status = await getAzureEmulatorStatus(azureConfig());
+ recordEmulatorSession(status.sessionId);
+ return status.ok
+ ? null
+ : ResponseBuilder.error("LocalStack Azure Emulator Not Ready", status.message);
+}
+
+export async function requireAzureCli(signal?: AbortSignal): Promise {
+ try {
+ await azCli(signal);
+ return null;
+ } catch (error) {
+ if (error instanceof AzCancelledError) return cancelled();
+ if (error instanceof AzResolveError && isRunningInDocker()) {
+ return ResponseBuilder.error(
+ "Azure CLI Not Available",
+ "This server's Docker image does not include the Azure CLI yet. To use the Azure tool, run the server with npx on a machine with the Azure CLI installed (see the README)."
+ );
+ }
+ const reasons =
+ error instanceof AzResolveError && error.reasons.length > 0
+ ? `\n\nSkipped:\n${error.reasons.map((reason) => `- ${reason}`).join("\n")}`
+ : "";
+ const message = error instanceof Error ? error.message : String(error);
+ return ResponseBuilder.error("Azure CLI Not Available", message + reasons);
+ }
+}
+
+/** Points the tool's own CLI profile at the emulator; a failed step comes with az's output. */
+export async function requireAzureCliConfigured(
+ signal?: AbortSignal
+): Promise {
+ try {
+ await ensureProfile(signal);
+ return null;
+ } catch (error) {
+ if (error instanceof AzCancelledError) return cancelled();
+ if (error instanceof BootstrapError && error.result.aborted) return cancelled();
+ const details =
+ error instanceof BootstrapError
+ ? [error.message, prepareStderr(error.result.stderr), error.result.spawnError]
+ : [error instanceof Error ? error.message : String(error)];
+ return ResponseBuilder.error(
+ "Azure CLI Profile Setup Failed",
+ details.filter(Boolean).join("\n\n")
+ );
+ }
+}
diff --git a/src/lib/azure/resolve-az.test.ts b/src/lib/azure/resolve-az.test.ts
new file mode 100644
index 0000000..536ac72
--- /dev/null
+++ b/src/lib/azure/resolve-az.test.ts
@@ -0,0 +1,74 @@
+import { AzResolveError, locateAz, parseAzVersion, type LocateAzContext } from "./resolve-az";
+
+const PREFIX = ["-X", "utf8", "-W", "ignore::SyntaxWarning", "-IBm", "azure.cli"];
+
+function locate(files: string[], over: Partial = {}) {
+ const present = new Set(files);
+ return locateAz({
+ env: {},
+ platform: "linux",
+ homedir: "/home/u",
+ isFile: (p) => present.has(p),
+ isExecutable: (p) => present.has(p),
+ ...over,
+ });
+}
+
+describe("locateAz", () => {
+ test("POSIX: the first az on PATH runs as it is, else a default location", () => {
+ const env = { PATH: "rel:/opt/a:/opt/b" };
+ expect(locate(["/opt/b/az", "/usr/bin/az"], { env })).toEqual({
+ file: "/opt/b/az",
+ prefixArgs: [],
+ });
+ expect(locate(["/opt/homebrew/bin/az"], { env }).file).toBe("/opt/homebrew/bin/az");
+ });
+
+ test("LOCALSTACK_AZ_PATH: a Python gets the module prefix; a bad path is an error", () => {
+ const azPath = "/opt/az/bin/python3";
+ expect(locate([azPath], { azPath })).toEqual({ file: azPath, prefixArgs: PREFIX });
+ expect(() => locate([], { azPath: "/missing/az" })).toThrow(
+ /absolute path to an existing file/
+ );
+ expect(() => locate(["az"], { azPath: "az" })).toThrow(/absolute path/);
+ });
+
+ test("skips a Windows az seen through WSL, which would use the user's own Windows profile", () => {
+ const wbin = "/mnt/c/Program Files/Microsoft SDKs/Azure/CLI2/wbin";
+ const error = (() => {
+ try {
+ locate([`${wbin}/az`], { env: { PATH: wbin } });
+ } catch (e) {
+ return e as AzResolveError;
+ }
+ })();
+ expect(error).toBeInstanceOf(AzResolveError);
+ expect(error?.reasons[0]).toContain("Windows Azure CLI");
+ expect(error?.message).toContain("brew install azure-cli");
+ });
+
+ test("Windows: az.cmd and az.bat map to their Python; the MSI's location without PATH", () => {
+ const cli = "C:\\PF\\Microsoft SDKs\\Azure\\CLI2";
+ const win = (files: string[], env: NodeJS.ProcessEnv) =>
+ locate(files, { platform: "win32", env });
+ expect(win([`${cli}\\wbin\\az.cmd`, `${cli}\\python.exe`], { Path: `"${cli}\\wbin"` })).toEqual(
+ {
+ file: `${cli}\\python.exe`,
+ prefixArgs: PREFIX,
+ azInstaller: "MSI",
+ }
+ );
+ const venv = ["C:\\venv\\Scripts\\az.bat", "C:\\venv\\Scripts\\python.exe"];
+ expect(win(venv, { PATH: "C:\\venv\\Scripts" })).toMatchObject({
+ file: venv[1],
+ azInstaller: "PIP",
+ });
+ expect(win([`${cli}\\python.exe`], { ProgramFiles: "C:\\PF" }).file).toBe(`${cli}\\python.exe`);
+ expect(() => win(["C:\\x\\az.cmd"], { PATH: "C:\\x" })).toThrow(AzResolveError);
+ });
+});
+
+test("parseAzVersion", () => {
+ expect(parseAzVersion('{"azure-cli": "2.91.0", "azure-cli-core": "2.91.0"}')).toBe("2.91.0");
+ expect(parseAzVersion("not json")).toBeUndefined();
+});
diff --git a/src/lib/azure/resolve-az.ts b/src/lib/azure/resolve-az.ts
new file mode 100644
index 0000000..813773e
--- /dev/null
+++ b/src/lib/azure/resolve-az.ts
@@ -0,0 +1,166 @@
+import { accessSync, constants, statSync } from "fs";
+import path from "path";
+
+/**
+ * Finding `az`: LOCALSTACK_AZ_PATH, then PATH in order, then the default install locations. On
+ * POSIX the launcher runs as it is. On Windows `az.cmd` and `az.bat` cannot be spawned without a
+ * shell, so each is mapped to the CLI's own Python, run as ` -X utf8 -IBm azure.cli`:
+ * `-I` keeps a planted `azure/cli/__main__.py` in the working directory from running in place of
+ * `az`, and `-X utf8` keeps non-ASCII values intact under `-I`.
+ */
+
+export interface AzExecutable {
+ /** The file spawned: the `az` launcher, or the CLI's own Python. */
+ file: string;
+ /** Arguments before the az argv: the module prefix for a Python, none for a launcher. */
+ prefixArgs: string[];
+ /** AZ_INSTALLER for a Python spawned directly, which no launcher sets. */
+ azInstaller?: "MSI" | "PIP";
+ version?: string;
+}
+
+export class AzResolveError extends Error {
+ constructor(
+ message: string,
+ public readonly reasons: string[] = []
+ ) {
+ super(message);
+ this.name = "AzResolveError";
+ }
+}
+
+export const AZURE_CLI_INSTALL_OPTIONS =
+ "Install the Azure CLI: https://learn.microsoft.com/cli/azure/install-azure-cli\n\n" +
+ "- Windows: winget install --exact --id Microsoft.AzureCLI\n" +
+ "- macOS: brew install azure-cli\n" +
+ "- Debian or Ubuntu: curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash\n\n" +
+ "If it is installed where this tool does not look, set LOCALSTACK_AZ_PATH to its launcher or its Python.";
+
+// `-W ignore::SyntaxWarning`: some extensions' code warns when compiled, which would end up in
+// every answer's stderr.
+const PYTHON_PREFIX = ["-X", "utf8", "-W", "ignore::SyntaxWarning", "-IBm", "azure.cli"];
+const PYTHON_NAME = /^python(\d+(\.\d+)*)?w?(\.exe)?$/i;
+
+export interface LocateAzContext {
+ /** LOCALSTACK_AZ_PATH. */
+ azPath?: string;
+ env: NodeJS.ProcessEnv;
+ platform: NodeJS.Platform;
+ homedir: string;
+ isFile(p: string): boolean;
+ /** POSIX: a regular file with an execute bit. */
+ isExecutable(p: string): boolean;
+}
+
+const succeeds = (check: () => boolean) => {
+ try {
+ return check();
+ } catch {
+ return false;
+ }
+};
+
+export const nodeResolveFs: Pick = {
+ isFile: (p) => succeeds(() => statSync(p).isFile()),
+ isExecutable: (p) => succeeds(() => (accessSync(p, constants.X_OK), statSync(p).isFile())),
+};
+
+/** An environment variable, ignoring case on Windows, where `Path` and `PATH` are one variable. */
+function envValue(env: NodeJS.ProcessEnv, name: string, platform: NodeJS.Platform) {
+ if (platform !== "win32") return env[name];
+ const key = Object.keys(env).find((k) => k.toUpperCase() === name);
+ return key === undefined ? undefined : env[key];
+}
+
+/** Windows: the Python behind a launcher, or why there is none. */
+function mapWindows(file: string, ctx: LocateAzContext): AzExecutable | string {
+ const P = path.win32;
+ const name = P.basename(file).toLowerCase();
+ const dir = P.dirname(file);
+ if (PYTHON_NAME.test(name)) {
+ const msi = /microsoft sdks[\\/]azure[\\/]cli2/i.test(file);
+ return { file, prefixArgs: PYTHON_PREFIX, azInstaller: msi ? "MSI" : "PIP" };
+ }
+ // The MSI's wbin\az.cmd runs ..\python.exe; pip's Scripts\az.bat the python.exe beside it (a
+ // venv) or one level up.
+ const candidates =
+ name === "az.cmd"
+ ? [P.join(dir, "..", "python.exe")]
+ : name === "az.bat"
+ ? [P.join(dir, "python.exe"), P.join(dir, "..", "python.exe")]
+ : [];
+ const python = candidates.find((p) => ctx.isFile(p));
+ if (python) {
+ return {
+ file: python,
+ prefixArgs: PYTHON_PREFIX,
+ azInstaller: name === "az.cmd" ? "MSI" : "PIP",
+ };
+ }
+ return `${file}: no python.exe next to it; set LOCALSTACK_AZ_PATH to the Azure CLI's python.exe`;
+}
+
+/** POSIX: a Python gets the module prefix, a launcher runs as it is. */
+function mapPosix(file: string): AzExecutable | string {
+ // In WSL a Windows az would not receive AZURE_CONFIG_DIR, and the bootstrap would rewrite the
+ // user's own Windows profile.
+ if (/^\/mnt\/[a-z]\//i.test(file) || /\.(exe|cmd|bat)$/i.test(file)) {
+ return `${file}: a Windows Azure CLI, which would use your own Windows profile; install the Linux Azure CLI`;
+ }
+ return { file, prefixArgs: PYTHON_NAME.test(path.posix.basename(file)) ? PYTHON_PREFIX : [] };
+}
+
+export function locateAz(ctx: LocateAzContext): AzExecutable {
+ const win = ctx.platform === "win32";
+ const P = win ? path.win32 : path.posix;
+ const map = (file: string) => (win ? mapWindows(file, ctx) : mapPosix(file));
+ if (ctx.azPath) {
+ if (!P.isAbsolute(ctx.azPath) || !ctx.isFile(ctx.azPath)) {
+ throw new AzResolveError(
+ `LOCALSTACK_AZ_PATH must be an absolute path to an existing file (the az launcher or the Azure CLI's Python); got "${ctx.azPath}".`
+ );
+ }
+ const mapped = map(ctx.azPath);
+ if (typeof mapped === "string") throw new AzResolveError(`LOCALSTACK_AZ_PATH: ${mapped}.`);
+ return mapped;
+ }
+
+ const pathDirs = (envValue(ctx.env, "PATH", ctx.platform) ?? "")
+ .split(win ? ";" : ":")
+ .map((entry) => entry.trim().replace(/^"(.*)"$/, "$1"))
+ .filter((entry) => P.isAbsolute(entry));
+ const names = win ? ["az.cmd", "az.bat"] : ["az"];
+ const known = win
+ ? ["ProgramFiles", "ProgramFiles(x86)"]
+ .map((name) => envValue(ctx.env, name.toUpperCase(), ctx.platform))
+ .filter((root): root is string => Boolean(root))
+ .map((root) => P.join(root, "Microsoft SDKs", "Azure", "CLI2", "python.exe"))
+ : [
+ "/usr/bin/az",
+ "/usr/local/bin/az",
+ "/opt/homebrew/bin/az",
+ P.join(ctx.homedir, ".local/bin/az"),
+ ];
+ const reasons: string[] = [];
+ for (const file of [...pathDirs.flatMap((dir) => names.map((n) => P.join(dir, n))), ...known]) {
+ if (!(win ? ctx.isFile(file) : ctx.isExecutable(file))) continue;
+ const mapped = map(file);
+ if (typeof mapped !== "string") return mapped;
+ if (!reasons.includes(mapped)) reasons.push(mapped);
+ }
+ throw new AzResolveError(
+ `The Azure CLI (az) was not found. ${AZURE_CLI_INSTALL_OPTIONS}`,
+ reasons
+ );
+}
+
+/** The azure-cli-core version from `az version -o json`. */
+export function parseAzVersion(stdout: string): string | undefined {
+ try {
+ const versions = JSON.parse(stdout) as Record;
+ const version = versions["azure-cli-core"] ?? versions["azure-cli"];
+ return typeof version === "string" ? version : undefined;
+ } catch {
+ return undefined;
+ }
+}
diff --git a/src/lib/azure/runner.test.ts b/src/lib/azure/runner.test.ts
new file mode 100644
index 0000000..2cc4765
--- /dev/null
+++ b/src/lib/azure/runner.test.ts
@@ -0,0 +1,118 @@
+import os from "os";
+import { runAz } from "./runner";
+
+// A Node script stands in for `az`: node -e