Skip to content

Commit 30e7007

Browse files
committed
feat(mcp): add aaz-dev MCP server
- Add MCP server (aaz-dev-mcp) exposing aaz-dev workspace, command editing, and CLI generation tools over the MCP protocol. - Layout under src/aaz_dev_mcp with services split by backend domain (command, cli) and tools split per-domain mirroring the services layer (config, command/{workspaces,arguments,examples}, cli/modules). - Argument editing supports clearing the arg group via a clear_group flag on update_argument.
1 parent 0d6ed23 commit 30e7007

21 files changed

Lines changed: 1405 additions & 0 deletions

Makefile

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
.PHONY: help mcp mcp-install venv
2+
3+
VENV ?= .venv
4+
PYTHON ?= python3
5+
ACTIVATE := . $(VENV)/bin/activate
6+
7+
help:
8+
@echo "Available targets:"
9+
@echo " make venv Create the local Python virtualenv ($(VENV))"
10+
@echo " make mcp-install Install aaz-dev-tools + aaz-dev-mcp into $(VENV)"
11+
@echo " make mcp Start the aaz-dev MCP server over stdio"
12+
13+
$(VENV)/bin/activate:
14+
$(PYTHON) -m venv $(VENV)
15+
16+
venv: $(VENV)/bin/activate
17+
18+
mcp-install: venv
19+
$(ACTIVATE) && pip install -e . && pip install -e src/aaz_dev_mcp
20+
21+
mcp: venv
22+
@if [ ! -x "$(VENV)/bin/aaz-dev-mcp" ]; then \
23+
echo "aaz-dev-mcp not installed; run 'make mcp-install' first." >&2; \
24+
exit 1; \
25+
fi
26+
$(ACTIVATE) && aaz-dev-mcp

src/aaz_dev_mcp/README.md

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# aaz-dev-mcp
2+
3+
A [Model Context Protocol](https://modelcontextprotocol.io) server that exposes
4+
[aaz-dev-tools](https://github.com/Azure/aaz-dev-tools) workflows as tools an
5+
LLM agent can call. It wraps the same Python controllers the web UI uses, so
6+
generated CLI code is identical to what you'd get by clicking through the
7+
browser.
8+
9+
## Scope (v1)
10+
11+
Reproduce the end-to-end flow of an Azure CLI swagger-version bump PR (e.g.
12+
[azure-cli#33222](https://github.com/Azure/azure-cli/pull/33222)):
13+
14+
1. Configure repo paths.
15+
2. Create or load a workspace.
16+
3. Add swagger resources at a specific API version.
17+
4. (Optional) tweak help text on command groups.
18+
5. Generate to the `aaz` repo.
19+
6. Select per-command versions for an azure-cli module and regenerate Python code.
20+
21+
## Install
22+
23+
This package depends on `aaz-dev-tools` itself being installed in the same
24+
Python environment.
25+
26+
```bash
27+
# from the repo root
28+
python -m venv .venv
29+
source .venv/bin/activate
30+
pip install -e . # installs aaz-dev-tools controllers
31+
pip install -e src/aaz_dev_mcp # installs aaz-dev-mcp + MCP SDK
32+
```
33+
34+
## Configuration
35+
36+
The server reads the same environment variables aaz-dev-tools uses:
37+
38+
| Variable | Purpose |
39+
| --- | --- |
40+
| `AAZ_PATH` | clone of `Azure/aaz` |
41+
| `AAZ_SWAGGER_PATH` | clone of `Azure/azure-rest-api-specs` |
42+
| `AAZ_CLI_PATH` | clone of `Azure/azure-cli` |
43+
| `AAZ_CLI_EXTENSION_PATH` | clone of `Azure/azure-cli-extensions` |
44+
| `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) |
45+
46+
You can also override at runtime by calling the `configure` tool first.
47+
48+
## Running
49+
50+
The server speaks MCP over **stdio**:
51+
52+
```bash
53+
aaz-dev-mcp
54+
```
55+
56+
### Claude Desktop / OpenCode config snippet
57+
58+
```json
59+
{
60+
"mcpServers": {
61+
"aaz-dev": {
62+
"command": "/absolute/path/to/.venv/bin/aaz-dev-mcp",
63+
"env": {
64+
"AAZ_PATH": "/Users/you/workspaces/aaz",
65+
"AAZ_SWAGGER_PATH": "/Users/you/workspaces/azure-rest-api-specs",
66+
"AAZ_CLI_PATH": "/Users/you/workspaces/azure-cli",
67+
"AAZ_CLI_EXTENSION_PATH": "/Users/you/workspaces/azure-cli-extensions"
68+
}
69+
}
70+
}
71+
}
72+
```
73+
74+
## Sharing workspaces with the Flask UI
75+
76+
Workspaces are not MCP-specific. Both the MCP server and the Flask UI in
77+
`aaz-dev` go through the same `WorkspaceManager`, which stores each
78+
workspace as `<AAZ_DEV_WORKSPACE_FOLDER>/<name>/{ws.json, Resources/...}`
79+
on disk.
80+
81+
If both processes use the same `AAZ_DEV_WORKSPACE_FOLDER` (the default
82+
`~/.aaz_dev/workspaces` for both), an MCP-created workspace shows up in
83+
the UI's workspace list automatically and you can edit its command tree,
84+
arguments, examples, etc., in the browser. Likewise, the MCP can `load`
85+
and modify workspaces that were created in the UI.
86+
87+
A few things to keep in mind:
88+
89+
- **No IPC** — if you have the UI open while the MCP writes the same
90+
workspace (or vice versa), the UI's in-memory copy is stale. Refresh.
91+
- **Optimistic concurrency**`WorkspaceManager.save()` compares a
92+
`ws.version` UTC timestamp against the on-disk copy
93+
(`workspace_manager.py:213`). Concurrent saves to the same workspace
94+
fail with `ResourceConflict("Workspace Changed after: ...")` rather
95+
than corrupting state. Re-load and retry.
96+
- **Other paths must also match** — if the UI's `AAZ_PATH` /
97+
`AAZ_CLI_PATH` / etc. point at different checkouts than the MCP's,
98+
`generate_to_aaz` and `update_*_module` from each side write to
99+
different repos. Keep them aligned (or be intentional about the split,
100+
e.g. UI -> real repos, MCP -> worktrees).
101+
102+
## Tools
103+
104+
| Tool | Purpose |
105+
| --- | --- |
106+
| `configure` | Set/inspect paths to aaz, swagger, cli, extensions, workspace folder. |
107+
| `list_workspaces` | List ws.json folders under the configured workspace folder. |
108+
| `create_workspace` | Create a new workspace. Errors if it already exists. |
109+
| `load_workspace` | Load an existing workspace's command tree. |
110+
| `add_swagger_resources` | Add ARM resource paths at a given API version into the workspace. |
111+
| `set_node_help` | Update help text / stage on a command-group node. |
112+
| `set_command_help` | Update help text / stage on a leaf command. |
113+
| `rename_command_group` | Rename or re-parent a command-group node, e.g. `node_names=["consumption","usage-detail"]``new_node_names=["consumption","usage"]`. |
114+
| `rename_command` | Rename or re-parent a leaf command, e.g. `leaf_names=["consumption","pricesheet","default","show"]``new_leaf_names=["consumption","pricesheet","show"]`. |
115+
| `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. |
116+
| `flatten_argument` | Flatten an object argument into its sub-arguments (e.g. `--time-period``--start-date` + `--end-date`). |
117+
| `unflatten_argument` | Inverse of `flatten_argument`. |
118+
| `list_command_examples` | Read the current examples on a leaf command. |
119+
| `add_command_example` | Append a single manual `{name, commands}` example (mirrors the editor's "Add Example" dialog). |
120+
| `add_examples_from_swagger` | Generate examples from the OpenAPI spec (the editor's "By OpenAPI Specification" button) and persist them; supports `replace=False/True`. |
121+
| `set_command_examples` | Replace the full example list on a leaf (pass `[]` to clear). |
122+
| `generate_to_aaz` | Export the workspace command tree to the `aaz` repo. |
123+
| `get_main_module` / `get_extension_module` | Read the current CLI profile for an azure-cli or extensions module. |
124+
| `update_main_module` / `update_extension_module` | Generate CLI code from a full profiles dict. Accepts `by_patch` (default `true`). |
125+
| `select_command_versions` | High-level: pick `{command: version}` and generate. Accepts `by_patch` (default `true`). |
126+
127+
## Typical workflow
128+
129+
1. `configure(...)` (or rely on env vars)
130+
2. `create_workspace(name="consumption-2024-08-01", mod_names="consumption", resource_provider="Microsoft.Consumption")`
131+
3. `add_swagger_resources(name=..., module="consumption", version="2024-08-01", resource_paths=[...])`
132+
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`).
133+
5. `set_node_help(name=..., node_names=["consumption","budget"], help={"short": "Manage consumption budgets."})` (optional)
134+
6. `generate_to_aaz(name=...)`
135+
7. `select_command_versions(target="main", module_name="consumption", by_patch=False, command_versions={"consumption budget create": "2024-08-01", ...})`
136+
137+
### `by_patch` semantics
138+
139+
`by_patch` (passed to `update_*_module` / `select_command_versions`) only
140+
controls whether unmodified commands re-read their existing config files.
141+
It does **not** preserve commands that are absent from the input profile —
142+
codegen always rewrites the entire `aaz/latest/` tree.
143+
144+
If you are introducing brand-new leaves (e.g. `consumption usage list`,
145+
`consumption reservation summary list`) that don't yet exist in the CLI
146+
module, you must pass `by_patch=False`. Otherwise the profile generator
147+
hits `AssertionError: command.cfg is not None`
148+
(`az_profile_generator.py:103`).
149+
150+
## Notes
151+
152+
- Each tool catches `ValueError` from `Config` setters and returns it as a
153+
structured error.
154+
- Errors raised by controllers (`exceptions.ResourceConflict`,
155+
`exceptions.InvalidAPIUsage`, etc.) propagate as MCP tool errors.
156+
- Workspaces use optimistic concurrency: a `ws.version` timestamp is checked
157+
on every save. If you run the Flask dev server and the MCP simultaneously
158+
and both write the same workspace, the second writer will error.

src/aaz_dev_mcp/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""aaz-dev-mcp: Model Context Protocol server wrapping aaz-dev-tools controllers."""
2+
3+
__version__ = "0.1.0"

src/aaz_dev_mcp/config.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Path configuration helpers.
2+
3+
The aaz-dev-tools backend reads paths from a global :class:`utils.config.Config`
4+
class. This module exposes a single :func:`apply_config` that mutates those
5+
class attributes, surfacing the validating setters' ``ValueError`` to the
6+
caller (the MCP ``configure`` tool).
7+
8+
Environment variables already honored by the upstream Config at import time:
9+
AAZ_PATH -> aaz repo checkout
10+
AAZ_SWAGGER_PATH -> azure-rest-api-specs checkout
11+
AAZ_CLI_PATH -> azure-cli checkout
12+
AAZ_CLI_EXTENSION_PATH -> azure-cli-extensions checkout
13+
AAZ_DEV_WORKSPACE_FOLDER -> where ws.json files live (default ~/.aaz/workspaces)
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from typing import Optional
19+
20+
# Importing ``aaz_dev`` first injects ``src/aaz_dev`` onto sys.path (see its
21+
# __init__.py), which makes the bare-namespace ``utils``/``command``/``cli``/
22+
# ``swagger`` imports used throughout the backend resolvable.
23+
import aaz_dev # noqa: F401
24+
from utils.config import Config
25+
26+
27+
def apply_config(
28+
aaz_path: Optional[str] = None,
29+
swagger_path: Optional[str] = None,
30+
cli_path: Optional[str] = None,
31+
cli_extension_path: Optional[str] = None,
32+
workspace_folder: Optional[str] = None,
33+
) -> dict:
34+
"""Update Config in place. Returns the resulting paths.
35+
36+
Each setter validates that the path exists and raises ``ValueError``
37+
otherwise; the caller (MCP tool) should turn that into a tool error.
38+
"""
39+
# The setters use Click's callback signature (cls, ctx, param, value).
40+
# We pass None for ctx/param since we're not in a Click context.
41+
if aaz_path is not None:
42+
Config.validate_and_setup_aaz_path(None, None, aaz_path)
43+
if swagger_path is not None:
44+
Config.validate_and_setup_swagger_path(None, None, swagger_path)
45+
if cli_path is not None:
46+
Config.validate_and_setup_cli_path(None, None, cli_path)
47+
if cli_extension_path is not None:
48+
Config.validate_and_setup_cli_extension_path(None, None, cli_extension_path)
49+
if workspace_folder is not None:
50+
Config.validate_and_setup_aaz_dev_workspace_folder(None, None, workspace_folder)
51+
return current_config()
52+
53+
54+
def current_config() -> dict:
55+
return {
56+
"aaz_path": Config.AAZ_PATH,
57+
"swagger_path": Config.SWAGGER_PATH,
58+
"cli_path": Config.CLI_PATH,
59+
"cli_extension_path": Config.CLI_EXTENSION_PATH,
60+
"workspace_folder": Config.AAZ_DEV_WORKSPACE_FOLDER,
61+
}

src/aaz_dev_mcp/pyproject.toml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
[build-system]
2+
requires = ["setuptools>=68", "wheel"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "aaz-dev-mcp"
7+
version = "0.1.0"
8+
description = "Model Context Protocol server for aaz-dev-tools (Azure CLI code generation)."
9+
readme = "README.md"
10+
requires-python = ">=3.10"
11+
authors = [{ name = "aaz-dev-tools contributors" }]
12+
license = { text = "MIT" }
13+
dependencies = [
14+
# MCP Python SDK (provides FastMCP + stdio transport)
15+
"mcp>=1.0",
16+
# The MCP imports aaz-dev controllers directly. The parent project must
17+
# already be installed in the same environment (`pip install -e .` at repo
18+
# root). We do NOT declare it as a path dependency here to keep this
19+
# package installable from any working directory.
20+
]
21+
22+
[project.scripts]
23+
aaz-dev-mcp = "aaz_dev_mcp.server:main"
24+
25+
[tool.setuptools]
26+
package-dir = {"aaz_dev_mcp" = "."}
27+
packages = [
28+
"aaz_dev_mcp",
29+
"aaz_dev_mcp.services",
30+
"aaz_dev_mcp.services.command",
31+
"aaz_dev_mcp.services.cli",
32+
"aaz_dev_mcp.tools",
33+
"aaz_dev_mcp.tools.command",
34+
"aaz_dev_mcp.tools.cli",
35+
]

src/aaz_dev_mcp/server.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""FastMCP server entrypoint for aaz-dev-mcp.
2+
3+
Run via the ``aaz-dev-mcp`` console script (installed by pyproject) or with
4+
``python -m aaz_dev_mcp.server``.
5+
6+
The server speaks the Model Context Protocol over stdio. All log output goes
7+
to stderr so it doesn't corrupt the JSON-RPC stream on stdout.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import logging
13+
import sys
14+
15+
16+
def _configure_logging() -> None:
17+
logging.basicConfig(
18+
level=logging.INFO,
19+
stream=sys.stderr,
20+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
21+
)
22+
23+
24+
def main() -> None:
25+
_configure_logging()
26+
27+
# Import lazily so logging is set up before any aaz-dev module logs.
28+
from mcp.server.fastmcp import FastMCP
29+
from .tools import register_tools
30+
31+
mcp = FastMCP("aaz-dev")
32+
register_tools(mcp)
33+
# FastMCP.run() defaults to stdio transport.
34+
mcp.run()
35+
36+
37+
if __name__ == "__main__":
38+
main()

src/aaz_dev_mcp/services/__init__.py

Whitespace-only changes.

src/aaz_dev_mcp/services/cli/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)