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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
.PHONY: help mcp mcp-install venv

VENV ?= .venv
PYTHON ?= python3
ACTIVATE := . $(VENV)/bin/activate

help:
@echo "Available targets:"
@echo " make venv Create the local Python virtualenv ($(VENV))"
@echo " make mcp-install Install aaz-dev-tools + aaz-dev-mcp into $(VENV)"
@echo " make mcp Start the aaz-dev MCP server over stdio"

$(VENV)/bin/activate:
$(PYTHON) -m venv $(VENV)

venv: $(VENV)/bin/activate

mcp-install: venv
$(ACTIVATE) && pip install -e . && pip install -e src/aaz_dev_mcp

mcp: venv
@if [ ! -x "$(VENV)/bin/aaz-dev-mcp" ]; then \
echo "aaz-dev-mcp not installed; run 'make mcp-install' first." >&2; \
exit 1; \
fi
$(ACTIVATE) && aaz-dev-mcp
158 changes: 158 additions & 0 deletions src/aaz_dev_mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# aaz-dev-mcp

A [Model Context Protocol](https://modelcontextprotocol.io) server that exposes
[aaz-dev-tools](https://github.com/Azure/aaz-dev-tools) workflows as tools an
LLM agent can call. It wraps the same Python controllers the web UI uses, so
generated CLI code is identical to what you'd get by clicking through the
browser.

## Scope (v1)

Reproduce the end-to-end flow of an Azure CLI swagger-version bump PR (e.g.
[azure-cli#33222](https://github.com/Azure/azure-cli/pull/33222)):

1. Configure repo paths.
2. Create or load a workspace.
3. Add swagger resources at a specific API version.
4. (Optional) tweak help text on command groups.
5. Generate to the `aaz` repo.
6. Select per-command versions for an azure-cli module and regenerate Python code.

## Install

This package depends on `aaz-dev-tools` itself being installed in the same
Python environment.

```bash
# from the repo root
python -m venv .venv
source .venv/bin/activate
pip install -e . # installs aaz-dev-tools controllers
pip install -e src/aaz_dev_mcp # installs aaz-dev-mcp + MCP SDK
```

## Configuration

The server reads the same environment variables aaz-dev-tools uses:

| Variable | Purpose |
| --- | --- |
| `AAZ_PATH` | clone of `Azure/aaz` |
| `AAZ_SWAGGER_PATH` | clone of `Azure/azure-rest-api-specs` |
| `AAZ_CLI_PATH` | clone of `Azure/azure-cli` |
| `AAZ_CLI_EXTENSION_PATH` | clone of `Azure/azure-cli-extensions` |
| `AAZ_DEV_WORKSPACE_FOLDER` | where workspace ws.json files live (default `~/.aaz_dev/workspaces` — same default as the Flask UI, so workspaces are shared by default) |

You can also override at runtime by calling the `configure` tool first.

## Running

The server speaks MCP over **stdio**:

```bash
aaz-dev-mcp
```

### Claude Desktop / OpenCode config snippet

```json
{
"mcpServers": {
"aaz-dev": {
"command": "/absolute/path/to/.venv/bin/aaz-dev-mcp",
"env": {
"AAZ_PATH": "/Users/you/workspaces/aaz",
"AAZ_SWAGGER_PATH": "/Users/you/workspaces/azure-rest-api-specs",
"AAZ_CLI_PATH": "/Users/you/workspaces/azure-cli",
"AAZ_CLI_EXTENSION_PATH": "/Users/you/workspaces/azure-cli-extensions"
}
}
}
}
```

## Sharing workspaces with the Flask UI

Workspaces are not MCP-specific. Both the MCP server and the Flask UI in
`aaz-dev` go through the same `WorkspaceManager`, which stores each
workspace as `<AAZ_DEV_WORKSPACE_FOLDER>/<name>/{ws.json, Resources/...}`
on disk.

If both processes use the same `AAZ_DEV_WORKSPACE_FOLDER` (the default
`~/.aaz_dev/workspaces` for both), an MCP-created workspace shows up in
the UI's workspace list automatically and you can edit its command tree,
arguments, examples, etc., in the browser. Likewise, the MCP can `load`
and modify workspaces that were created in the UI.

A few things to keep in mind:

- **No IPC** — if you have the UI open while the MCP writes the same
workspace (or vice versa), the UI's in-memory copy is stale. Refresh.
- **Optimistic concurrency** — `WorkspaceManager.save()` compares a
`ws.version` UTC timestamp against the on-disk copy
(`workspace_manager.py:213`). Concurrent saves to the same workspace
fail with `ResourceConflict("Workspace Changed after: ...")` rather
than corrupting state. Re-load and retry.
- **Other paths must also match** — if the UI's `AAZ_PATH` /
`AAZ_CLI_PATH` / etc. point at different checkouts than the MCP's,
`generate_to_aaz` and `update_*_module` from each side write to
different repos. Keep them aligned (or be intentional about the split,
e.g. UI -> real repos, MCP -> worktrees).

## Tools

| Tool | Purpose |
| --- | --- |
| `configure` | Set/inspect paths to aaz, swagger, cli, extensions, workspace folder. |
| `list_workspaces` | List ws.json folders under the configured workspace folder. |
| `create_workspace` | Create a new workspace. Errors if it already exists. |
| `load_workspace` | Load an existing workspace's command tree. |
| `add_swagger_resources` | Add ARM resource paths at a given API version into the workspace. |
| `set_node_help` | Update help text / stage on a command-group node. |
| `set_command_help` | Update help text / stage on a leaf command. |
| `rename_command_group` | Rename or re-parent a command-group node, e.g. `node_names=["consumption","usage-detail"]` → `new_node_names=["consumption","usage"]`. |
| `rename_command` | Rename or re-parent a leaf command, e.g. `leaf_names=["consumption","pricesheet","default","show"]` → `new_leaf_names=["consumption","pricesheet","show"]`. |
| `update_argument` | Patch a single argument on a leaf (options/help/stage/hide/group/singular_options). Use to add aliases like `--name`/`-n` to `--budget-name`. Pass `clear_group=True` to ungroup an arg. |
| `flatten_argument` | Flatten an object argument into its sub-arguments (e.g. `--time-period` → `--start-date` + `--end-date`). |
| `unflatten_argument` | Inverse of `flatten_argument`. |
| `list_command_examples` | Read the current examples on a leaf command. |
| `add_command_example` | Append a single manual `{name, commands}` example (mirrors the editor's "Add Example" dialog). |
| `add_examples_from_swagger` | Generate examples from the OpenAPI spec (the editor's "By OpenAPI Specification" button) and persist them; supports `replace=False/True`. |
| `set_command_examples` | Replace the full example list on a leaf (pass `[]` to clear). |
| `generate_to_aaz` | Export the workspace command tree to the `aaz` repo. |
| `get_main_module` / `get_extension_module` | Read the current CLI profile for an azure-cli or extensions module. |
| `update_main_module` / `update_extension_module` | Generate CLI code from a full profiles dict. Accepts `by_patch` (default `true`). |
| `select_command_versions` | High-level: pick `{command: version}` and generate. Accepts `by_patch` (default `true`). |

## Typical workflow

1. `configure(...)` (or rely on env vars)
2. `create_workspace(name="consumption-2024-08-01", mod_names="consumption", resource_provider="Microsoft.Consumption")`
3. `add_swagger_resources(name=..., module="consumption", version="2024-08-01", resource_paths=[...])`
4. `rename_command_group(name=..., node_names=["consumption","usage-detail"], new_node_names=["consumption","usage"])` (and similar for `reservation-summary` → `reservation summary`, `reservation-detail` → `reservation detail`; `rename_command` for `pricesheet default show` → `pricesheet show`).
5. `set_node_help(name=..., node_names=["consumption","budget"], help={"short": "Manage consumption budgets."})` (optional)
6. `generate_to_aaz(name=...)`
7. `select_command_versions(target="main", module_name="consumption", by_patch=False, command_versions={"consumption budget create": "2024-08-01", ...})`

### `by_patch` semantics

`by_patch` (passed to `update_*_module` / `select_command_versions`) only
controls whether unmodified commands re-read their existing config files.
It does **not** preserve commands that are absent from the input profile —
codegen always rewrites the entire `aaz/latest/` tree.

If you are introducing brand-new leaves (e.g. `consumption usage list`,
`consumption reservation summary list`) that don't yet exist in the CLI
module, you must pass `by_patch=False`. Otherwise the profile generator
hits `AssertionError: command.cfg is not None`
(`az_profile_generator.py:103`).

## Notes

- Each tool catches `ValueError` from `Config` setters and returns it as a
structured error.
- Errors raised by controllers (`exceptions.ResourceConflict`,
`exceptions.InvalidAPIUsage`, etc.) propagate as MCP tool errors.
- Workspaces use optimistic concurrency: a `ws.version` timestamp is checked
on every save. If you run the Flask dev server and the MCP simultaneously
and both write the same workspace, the second writer will error.
3 changes: 3 additions & 0 deletions src/aaz_dev_mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""aaz-dev-mcp: Model Context Protocol server wrapping aaz-dev-tools controllers."""

__version__ = "0.1.0"
61 changes: 61 additions & 0 deletions src/aaz_dev_mcp/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Path configuration helpers.

The aaz-dev-tools backend reads paths from a global :class:`utils.config.Config`
class. This module exposes a single :func:`apply_config` that mutates those
class attributes, surfacing the validating setters' ``ValueError`` to the
caller (the MCP ``configure`` tool).

Environment variables already honored by the upstream Config at import time:
AAZ_PATH -> aaz repo checkout
AAZ_SWAGGER_PATH -> azure-rest-api-specs checkout
AAZ_CLI_PATH -> azure-cli checkout
AAZ_CLI_EXTENSION_PATH -> azure-cli-extensions checkout
AAZ_DEV_WORKSPACE_FOLDER -> where ws.json files live (default ~/.aaz/workspaces)
"""

from __future__ import annotations

from typing import Optional

# Importing ``aaz_dev`` first injects ``src/aaz_dev`` onto sys.path (see its
# __init__.py), which makes the bare-namespace ``utils``/``command``/``cli``/
# ``swagger`` imports used throughout the backend resolvable.
import aaz_dev # noqa: F401
from utils.config import Config


def apply_config(
aaz_path: Optional[str] = None,
swagger_path: Optional[str] = None,
cli_path: Optional[str] = None,
cli_extension_path: Optional[str] = None,
workspace_folder: Optional[str] = None,
) -> dict:
"""Update Config in place. Returns the resulting paths.

Each setter validates that the path exists and raises ``ValueError``
otherwise; the caller (MCP tool) should turn that into a tool error.
"""
# The setters use Click's callback signature (cls, ctx, param, value).
# We pass None for ctx/param since we're not in a Click context.
if aaz_path is not None:
Config.validate_and_setup_aaz_path(None, None, aaz_path)
if swagger_path is not None:
Config.validate_and_setup_swagger_path(None, None, swagger_path)
if cli_path is not None:
Config.validate_and_setup_cli_path(None, None, cli_path)
if cli_extension_path is not None:
Config.validate_and_setup_cli_extension_path(None, None, cli_extension_path)
if workspace_folder is not None:
Config.validate_and_setup_aaz_dev_workspace_folder(None, None, workspace_folder)
return current_config()


def current_config() -> dict:
return {
"aaz_path": Config.AAZ_PATH,
"swagger_path": Config.SWAGGER_PATH,
"cli_path": Config.CLI_PATH,
"cli_extension_path": Config.CLI_EXTENSION_PATH,
"workspace_folder": Config.AAZ_DEV_WORKSPACE_FOLDER,
}
35 changes: 35 additions & 0 deletions src/aaz_dev_mcp/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "aaz-dev-mcp"
version = "0.1.0"
description = "Model Context Protocol server for aaz-dev-tools (Azure CLI code generation)."
readme = "README.md"
requires-python = ">=3.10"
authors = [{ name = "aaz-dev-tools contributors" }]
license = { text = "MIT" }
dependencies = [
# MCP Python SDK (provides FastMCP + stdio transport)
"mcp>=1.0",
# The MCP imports aaz-dev controllers directly. The parent project must
# already be installed in the same environment (`pip install -e .` at repo
# root). We do NOT declare it as a path dependency here to keep this
# package installable from any working directory.
]

[project.scripts]
aaz-dev-mcp = "aaz_dev_mcp.server:main"

[tool.setuptools]
package-dir = {"aaz_dev_mcp" = "."}
packages = [
"aaz_dev_mcp",
"aaz_dev_mcp.services",
"aaz_dev_mcp.services.command",
"aaz_dev_mcp.services.cli",
"aaz_dev_mcp.tools",
"aaz_dev_mcp.tools.command",
"aaz_dev_mcp.tools.cli",
]
38 changes: 38 additions & 0 deletions src/aaz_dev_mcp/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""FastMCP server entrypoint for aaz-dev-mcp.

Run via the ``aaz-dev-mcp`` console script (installed by pyproject) or with
``python -m aaz_dev_mcp.server``.

The server speaks the Model Context Protocol over stdio. All log output goes
to stderr so it doesn't corrupt the JSON-RPC stream on stdout.
"""

from __future__ import annotations

import logging
import sys


def _configure_logging() -> None:
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)


def main() -> None:
_configure_logging()

# Import lazily so logging is set up before any aaz-dev module logs.
from mcp.server.fastmcp import FastMCP
from .tools import register_tools

mcp = FastMCP("aaz-dev")
register_tools(mcp)
# FastMCP.run() defaults to stdio transport.
mcp.run()


if __name__ == "__main__":
main()
Empty file.
Empty file.
Loading
Loading