Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f95ae5e
feat(azure): add the Azure emulator tool and Azure lifecycle support
DrisDary Sep 28, 2026
371eb9a
chore(azure): keep internal references out of the repository
DrisDary Sep 29, 2026
b4a1dcd
docs(azure): tighten the README's Azure section and the test wording
DrisDary Sep 29, 2026
bb11f44
ci(azure): no model evals in CI, fail fast on licence errors, scope t…
DrisDary Sep 29, 2026
7fb62bb
fix(azure): address the review of the Azure tool
DrisDary Sep 29, 2026
8d6ba79
ci(azure): run the Azure jobs with the LOCALSTACK_AUTH_TOKEN_AZURE se…
DrisDary Sep 29, 2026
67dd802
fix(azure): bind-mount the emulator's state folder so app deployments…
DrisDary Sep 29, 2026
89ed16a
ci: run every job with the LOCALSTACK_AUTH_TOKEN_AZURE secret
DrisDary Sep 29, 2026
1f12bf3
ci: run every job with the one LOCALSTACK_AUTH_TOKEN secret
DrisDary Sep 29, 2026
73910a8
fix(deps): bump @grpc/grpc-js to 1.14.5 for GHSA-m9gg-hp2v-232j
DrisDary Oct 5, 2026
f73e70a
test: skip the docs checks while the docs search service is down
DrisDary Oct 5, 2026
6669013
fix(azure): keep only az command words in the analytics command path
DrisDary Oct 5, 2026
f1cdbd4
ci: back to main's workflows
DrisDary Oct 8, 2026
cbadcf5
refactor(azure): cut the Azure client to the tool, service: azure and…
DrisDary Oct 8, 2026
78fc944
docs: the Azure tool's setup and settings
DrisDary Oct 8, 2026
4ad89e1
fix(azure): address the second review of the Azure tool
DrisDary Oct 12, 2026
ee807d9
Merge remote-tracking branch 'origin/main' into feat/azure-client
DrisDary Oct 12, 2026
9e59784
fix(azure): address the third review of the Azure tool
DrisDary Oct 12, 2026
edd0136
docs(azure): move the Azure tool section to docs/AZURE.md
DrisDary Oct 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/azure-smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Azure smoke

# The Azure tool against the LocalStack Azure emulator, weekly and on demand.
on:
workflow_dispatch:
Comment thread
DrisDary marked this conversation as resolved.
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
16 changes: 14 additions & 2 deletions README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions data/sample-azure/hello.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
hello from the LocalStack MCP server Azure stage
11 changes: 11 additions & 0 deletions docs/AZURE.md
Original file line number Diff line number Diff line change
@@ -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 <extension>`.
- 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.
4 changes: 4 additions & 0 deletions docs/DOCKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
35 changes: 35 additions & 0 deletions playwright.config.mjs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -23,6 +32,7 @@ export default defineConfig({
projects: [
{
name: "localstack-mcp-server",
testIgnore: /azure-offline\.spec\.mjs$/,
use: {
mcpConfig: {
transport: "stdio",
Expand All @@ -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",
},
},
},
},
],
});
49 changes: 49 additions & 0 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -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-<port>.",
"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"
}
]
}
Expand Down
1 change: 1 addition & 0 deletions src/core/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const SHUTDOWN_TIMEOUT_MS = 1000;

export const TOOL_ARG_ALLOWLIST: Record<string, string[]> = {
"localstack-aws-client": ["command"],
"localstack-azure-client": ["command"],
"localstack-aws-replicator": [
"action",
"replication_type",
Expand Down
84 changes: 84 additions & 0 deletions src/lib/azure/argv.ts
Original file line number Diff line number Diff line change
@@ -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 `\<newline>` 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;
}
Loading
Loading