diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..0a4ebb9e --- /dev/null +++ b/Makefile @@ -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 diff --git a/src/aaz_dev_mcp/README.md b/src/aaz_dev_mcp/README.md new file mode 100644 index 00000000..55f0a038 --- /dev/null +++ b/src/aaz_dev_mcp/README.md @@ -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 `//{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. diff --git a/src/aaz_dev_mcp/__init__.py b/src/aaz_dev_mcp/__init__.py new file mode 100644 index 00000000..dc1d3d14 --- /dev/null +++ b/src/aaz_dev_mcp/__init__.py @@ -0,0 +1,3 @@ +"""aaz-dev-mcp: Model Context Protocol server wrapping aaz-dev-tools controllers.""" + +__version__ = "0.1.0" diff --git a/src/aaz_dev_mcp/config.py b/src/aaz_dev_mcp/config.py new file mode 100644 index 00000000..f78572f8 --- /dev/null +++ b/src/aaz_dev_mcp/config.py @@ -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, + } diff --git a/src/aaz_dev_mcp/pyproject.toml b/src/aaz_dev_mcp/pyproject.toml new file mode 100644 index 00000000..1de301ec --- /dev/null +++ b/src/aaz_dev_mcp/pyproject.toml @@ -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", +] diff --git a/src/aaz_dev_mcp/server.py b/src/aaz_dev_mcp/server.py new file mode 100644 index 00000000..96d6a9a5 --- /dev/null +++ b/src/aaz_dev_mcp/server.py @@ -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() diff --git a/src/aaz_dev_mcp/services/__init__.py b/src/aaz_dev_mcp/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aaz_dev_mcp/services/cli/__init__.py b/src/aaz_dev_mcp/services/cli/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aaz_dev_mcp/services/cli/modules.py b/src/aaz_dev_mcp/services/cli/modules.py new file mode 100644 index 00000000..6c176983 --- /dev/null +++ b/src/aaz_dev_mcp/services/cli/modules.py @@ -0,0 +1,142 @@ +"""CLI module operations: wraps AzMainManager / AzExtensionManager.""" + +from __future__ import annotations + +from typing import Any, Mapping, Optional + +import aaz_dev # noqa: F401 (injects src/aaz_dev onto sys.path) +from cli.controller.az_module_manager import AzMainManager, AzExtensionManager +from cli.model.view import CLIModule +from utils import exceptions + + +def _manager(target: str): + if target == "main": + return AzMainManager() + if target == "extension": + return AzExtensionManager() + raise exceptions.InvalidAPIUsage( + f"target must be 'main' or 'extension', got {target!r}") + + +def get_module(target: str, module_name: str) -> dict: + """Load the CLI view of a module (the current profile/command tree).""" + mgr = _manager(target) + module = mgr.load_module(module_name) + return module.to_primitive() + + +def update_module( + target: str, + module_name: str, + profiles: Mapping[str, Any], + by_patch: bool = True, +) -> dict: + """Generate CLI code for a module. + + ``profiles`` is the same shape the Flask PUT/PATCH route consumes; e.g.:: + + { + "latest": { + "name": "latest", + "commandGroups": { + "consumption": { + "names": ["consumption"], + "commandGroups": { + "budget": { + "names": ["consumption", "budget"], + "commands": { + "create": { + "names": ["consumption", "budget", "create"], + "registered": true, + "version": "2024-08-01" + } + } + } + } + } + } + } + } + + ``by_patch=True`` (default) merges with the existing module instead of + overwriting; that matches the PATCH route the UI uses. + """ + mgr = _manager(target) + module = CLIModule({"name": module_name, "profiles": profiles}) + module = mgr.update_module(module_name, module.profiles, by_patch=by_patch) + return module.to_primitive() + + +def select_command_versions( + target: str, + module_name: str, + profile: str, + command_versions: Mapping[str, str], + by_patch: bool = True, +) -> dict: + """Higher-level helper: pick a version for each named command and + generate code. + + ``command_versions`` maps space-separated command paths (without the leaf + being a wait command) to their desired API version, e.g.:: + + { + "consumption budget create": "2024-08-01", + "consumption budget delete": "2024-08-01", + } + + All commands are marked ``registered=True``. The resulting profile is + passed to :func:`update_module` with ``by_patch=True`` so untouched + commands in the module are preserved. + """ + command_groups: dict = {} + for cmd_path, version in command_versions.items(): + parts = cmd_path.strip().split() + if len(parts) < 2: + raise exceptions.InvalidAPIUsage( + f"command path must have at least one group + leaf: {cmd_path!r}") + group_names = parts[:-1] + leaf_name = parts[-1] + + cur = command_groups + for depth, name in enumerate(group_names, start=1): + full_names = parts[: depth] + if name not in cur: + cur[name] = { + "names": full_names, + "commandGroups": {}, + "commands": {}, + } + cur = cur[name] + # descend into the nested commandGroups dict for next iteration + if depth < len(group_names): + cur = cur["commandGroups"] + + cur.setdefault("commands", {})[leaf_name] = { + "names": parts, + "registered": True, + "version": version, + } + + # prune empty commandGroups/commands placeholders for cleanliness + _prune(command_groups) + + profiles = { + profile: { + "name": profile, + "commandGroups": command_groups, + } + } + return update_module(target, module_name, profiles, by_patch=by_patch) + + +def _prune(groups: dict) -> None: + for group in groups.values(): + sub = group.get("commandGroups") + if sub: + _prune(sub) + if sub == {} or sub is None: + group.pop("commandGroups", None) + if not group.get("commands"): + group.pop("commands", None) diff --git a/src/aaz_dev_mcp/services/command/__init__.py b/src/aaz_dev_mcp/services/command/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aaz_dev_mcp/services/command/arguments.py b/src/aaz_dev_mcp/services/command/arguments.py new file mode 100644 index 00000000..e9509f59 --- /dev/null +++ b/src/aaz_dev_mcp/services/command/arguments.py @@ -0,0 +1,156 @@ +"""Argument editing on workspace command leaves. + +Mirrors backend ``command/controller/workspace_cfg_editor.py`` argument +operations: patch, flatten, unflatten. +""" + +from __future__ import annotations + +from typing import Any, Optional + +import aaz_dev # noqa: F401 (injects src/aaz_dev onto sys.path) +from command.controller.workspace_manager import WorkspaceManager +from utils import exceptions + + +def _load_cfg_editor_for_command(manager: WorkspaceManager, command: list[str]): + """Resolve a command path (no 'aaz' root) to (cfg_editor, *node_names, leaf_name).""" + if not command or len(command) < 2: + raise exceptions.InvalidAPIUsage( + "command path must include at least one group and a leaf") + *node_names, leaf_name = command + leaf = manager.find_command_tree_leaf(*node_names, leaf_name) + if not leaf: + raise exceptions.ResourceNotFind( + f"Command not found: {' '.join(command)}") + cfg_editor = manager.load_cfg_editor_by_command(leaf) + return cfg_editor, node_names, leaf_name + + +def update_argument( + name: str, + command: list[str], + arg_var: str, + options: Optional[list[str]] = None, + singular_options: Optional[list[str]] = None, + help: Optional[dict] = None, + stage: Optional[str] = None, + hide: Optional[bool] = None, + group: Optional[str] = None, + clear_group: bool = False, + default: Any = "__unset__", + required: Optional[bool] = None, # currently unused; reserved +) -> dict: + """Patch a single argument of a leaf command. + + Mirrors PATCH /Workspaces//.../Arguments/ in the editor API. + + - command: full command path without the 'aaz' root, e.g. + ['consumption', 'budget', 'create']. + - arg_var: argument variable, e.g. '$Path.budgetName' or + '$parameters.properties.timePeriod'. Inspect with the editor UI or + look at the workspace's cfg.json. + - options: replace the argument's option list (e.g. ['name', 'n', 'budget-name'] + to add aliases to --budget-name). + - singular_options: only valid for array / cls args; used by the UI when + promoting a multi-value option to also accept a singular form. + - help: {'short': str, 'lines': [str], 'refCommands': [str]}. + - stage: 'Stable' | 'Preview' | 'Experimental'. + - hide: bool. (Cannot hide required arguments.) + - group: arg group name. Pass a non-empty string to set; pass + ``clear_group=True`` to remove the arg from any named group + (it then appears in the default un-named group). + - clear_group: if True, set ``arg.group`` back to None. Takes + precedence over ``group``. Use this instead of passing an empty + string, which some MCP transports mangle. + - default: pass any JSON value, or null to clear; omit (sentinel) to leave alone. + """ + manager = WorkspaceManager(name) + manager.load() + cfg_editor, node_names, leaf_name = _load_cfg_editor_for_command(manager, command) + arg, _ = cfg_editor.find_arg_by_var(*node_names, leaf_name, arg_var=arg_var) + if not arg: + raise exceptions.ResourceNotFind( + f"Argument not found on {' '.join(command)}: {arg_var}") + + kwargs: dict[str, Any] = {} + if options is not None: + kwargs["options"] = options + if singular_options is not None: + kwargs["singularOptions"] = singular_options + if help is not None: + kwargs["help"] = help + if stage is not None: + kwargs["stage"] = stage + if hide is not None: + kwargs["hide"] = hide + if clear_group: + kwargs["group"] = None + elif group is not None: + kwargs["group"] = group + if default != "__unset__": + kwargs["default"] = default + if not kwargs: + raise exceptions.InvalidAPIUsage( + "update_argument requires at least one field to change") + + cfg_editor.update_arg_by_var(*node_names, leaf_name, arg_var=arg_var, **kwargs) + manager.save() + arg, _ = cfg_editor.find_arg_by_var(*node_names, leaf_name, arg_var=arg_var) + return arg.to_primitive() + + +def flatten_argument( + name: str, + command: list[str], + arg_var: str, + sub_args_options: Optional[dict[str, list[str]]] = None, +) -> dict: + """Flatten an object argument into its sub-arguments. + + Mirrors POST /Workspaces//.../Arguments//Flatten. + + Example: flatten ``$parameters.properties.timePeriod`` on + ``consumption budget create`` so that the user sees ``--start-date`` + and ``--end-date`` directly instead of ``--time-period``. + + - command: full command path without the 'aaz' root. + - arg_var: object argument variable to flatten. + - sub_args_options: optional ``{sub_arg_var: [option, ...]}`` mapping + to rename the flattened sub-arguments. + """ + manager = WorkspaceManager(name) + manager.load() + cfg_editor, node_names, leaf_name = _load_cfg_editor_for_command(manager, command) + arg, _ = cfg_editor.find_arg_by_var(*node_names, leaf_name, arg_var=arg_var) + if not arg: + raise exceptions.ResourceNotFind( + f"Argument not found on {' '.join(command)}: {arg_var}") + cfg_editor.flatten_arg( + *node_names, leaf_name, arg_var=arg_var, sub_args_options=sub_args_options) + manager.save() + return { + "command": command, + "flattened": arg_var, + "sub_args_options": sub_args_options or {}, + } + + +def unflatten_argument( + name: str, + command: list[str], + arg_var: str, + options: list[str], + help: dict, + sub_args_options: Optional[dict[str, list[str]]] = None, +) -> dict: + """Inverse of flatten_argument: re-wrap sub-args under a single object arg.""" + manager = WorkspaceManager(name) + manager.load() + cfg_editor, node_names, leaf_name = _load_cfg_editor_for_command(manager, command) + cfg_editor.unflatten_arg( + *node_names, leaf_name, arg_var=arg_var, + options=options, help=help, sub_args_options=sub_args_options) + manager.save() + arg, _ = cfg_editor.find_arg_by_var(*node_names, leaf_name, arg_var=arg_var) + return arg.to_primitive() diff --git a/src/aaz_dev_mcp/services/command/examples.py b/src/aaz_dev_mcp/services/command/examples.py new file mode 100644 index 00000000..907d69f2 --- /dev/null +++ b/src/aaz_dev_mcp/services/command/examples.py @@ -0,0 +1,108 @@ +"""Example management on workspace command leaves. + +Mirrors backend ``command/controller/workspace_manager.py`` example operations +plus ``swagger/controller/example_generator.py`` for swagger-derived examples. +""" + +from __future__ import annotations + +import aaz_dev # noqa: F401 (injects src/aaz_dev onto sys.path) +from command.controller.workspace_manager import WorkspaceManager +from utils import exceptions + + +def _find_leaf(manager: WorkspaceManager, command: list[str]): + if not command or len(command) < 2: + raise exceptions.InvalidAPIUsage( + "command path must include at least one group and a leaf") + *node_names, leaf_name = command + leaf = manager.find_command_tree_leaf(*node_names, leaf_name) + if not leaf: + raise exceptions.ResourceNotFind( + f"Command not found: {' '.join(command)}") + return leaf + + +def list_command_examples(name: str, command: list[str]) -> list[dict]: + """Return the current examples on a leaf command.""" + manager = WorkspaceManager(name) + manager.load() + leaf = _find_leaf(manager, command) + return [e.to_primitive() for e in (leaf.examples or [])] + + +def set_command_examples( + name: str, + command: list[str], + examples: list[dict], +) -> list[dict]: + """Replace the full example list on a leaf command. + + Each example must have ``{"name": str, "commands": [str, ...]}``. + Pass an empty list to clear examples. + """ + manager = WorkspaceManager(name) + manager.load() + leaf = _find_leaf(manager, command) + leaf = manager.update_command_tree_leaf_examples(*leaf.names, examples=examples) + manager.save() + return [e.to_primitive() for e in (leaf.examples or [])] + + +def add_command_example( + name: str, + command: list[str], + example_name: str, + commands: list[str], +) -> list[dict]: + """Append a single manual example to a leaf command. + + - example_name: short label shown in CLI help + - commands: list of CLI invocation strings, e.g. + ['consumption budget create -n my-budget --amount 100 ...'] + """ + if not example_name or not commands: + raise exceptions.InvalidAPIUsage( + "example_name and commands must be non-empty") + manager = WorkspaceManager(name) + manager.load() + leaf = _find_leaf(manager, command) + existing = [e.to_primitive() for e in (leaf.examples or [])] + existing.append({"name": example_name, "commands": list(commands)}) + leaf = manager.update_command_tree_leaf_examples(*leaf.names, examples=existing) + manager.save() + return [e.to_primitive() for e in (leaf.examples or [])] + + +def add_examples_from_swagger( + name: str, + command: list[str], + replace: bool = False, +) -> list[dict]: + """Generate examples from the OpenAPI specification ('By OpenAPI Specification' + button in the UI) and persist them on the leaf. + + - replace=False (default): append generated examples to any existing ones. + - replace=True: drop existing examples first. + """ + manager = WorkspaceManager(name) + manager.load() + leaf = _find_leaf(manager, command) + cfg_editor = manager.load_cfg_editor_by_command(leaf) + cfg_command = cfg_editor.find_command(*leaf.names) + if not cfg_command: + raise exceptions.ResourceNotFind( + f"Command config not found: {' '.join(command)}") + + generated = manager.generate_examples_by_swagger(leaf, cfg_command) + generated_dicts = [e.to_primitive() for e in generated] + + if replace: + merged = generated_dicts + else: + merged = [e.to_primitive() for e in (leaf.examples or [])] + merged.extend(generated_dicts) + + leaf = manager.update_command_tree_leaf_examples(*leaf.names, examples=merged) + manager.save() + return [e.to_primitive() for e in (leaf.examples or [])] diff --git a/src/aaz_dev_mcp/services/command/workspaces.py b/src/aaz_dev_mcp/services/command/workspaces.py new file mode 100644 index 00000000..597fa99d --- /dev/null +++ b/src/aaz_dev_mcp/services/command/workspaces.py @@ -0,0 +1,191 @@ +"""Workspace lifecycle operations: thin wrappers around WorkspaceManager. + +Mirrors the backend ``command/controller/workspace_manager.py`` surface that +deals with the workspace itself (create/load/list/save) and its command tree +(rename nodes/leaves, set help, add swagger resources, generate to aaz). +""" + +from __future__ import annotations + +import os +from typing import Iterable, Optional + +import aaz_dev # noqa: F401 (injects src/aaz_dev onto sys.path) +from command.controller.workspace_manager import WorkspaceManager +from swagger.utils.tools import swagger_resource_path_to_resource_id +from utils import exceptions + + +def list_workspaces() -> list[dict]: + return WorkspaceManager.list_workspaces() + + +def create_workspace( + name: str, + plane: str, + mod_names: str, + resource_provider: str, + source: str, +) -> dict: + """Create a new workspace. Raises ResourceConflict if it already exists. + + WorkspaceManager.new already enforces "fails if exists" via a + ResourceConflict check on the ws.json path (workspace_manager.py:52-54). + """ + manager = WorkspaceManager.new( + name=name, + plane=plane, + mod_names=mod_names, + resource_provider=resource_provider, + source=source, + ) + manager.save() + return _ws_summary(manager) + + +def load_workspace(name: str) -> dict: + manager = WorkspaceManager(name) + manager.load() + return _ws_summary(manager) + + +def add_swagger_resources( + name: str, + module: str, + version: str, + resource_paths: Iterable[str], +) -> dict: + """Add resources to a workspace's command tree by swagger path. + + ``resource_paths`` are raw swagger paths (e.g. + ``/subscriptions/{subscriptionId}/providers/Microsoft.Consumption/budgets``); + they are normalized to resource ids using the same helper the Flask layer + uses. + """ + manager = WorkspaceManager(name) + manager.load() + resources = [ + {"id": swagger_resource_path_to_resource_id(p)} + for p in resource_paths + ] + if not resources: + raise exceptions.InvalidAPIUsage("resource_paths must not be empty") + manager.add_new_resources_by_swagger( + mod_names=module, + version=version, + resources=resources, + ) + manager.save() + return {"added": [r["id"] for r in resources]} + + +def set_node_help( + name: str, + node_names: list[str], + help: Optional[dict] = None, + stage: Optional[str] = None, +) -> dict: + """Update help and/or stage on a command-tree node (group). + + ``node_names`` should NOT include the root "aaz" prefix; pass the + user-visible names, e.g. ``["consumption", "budget"]``. + """ + manager = WorkspaceManager(name) + manager.load() + node = None + if help is not None: + node = manager.update_command_tree_node_help(*node_names, help=help) + if stage is not None: + node = manager.update_command_tree_node_stage(*node_names, stage=stage) + if node is None: + raise exceptions.InvalidAPIUsage( + "set_node_help requires at least one of 'help' or 'stage'") + manager.save() + return node.to_primitive() + + +def set_command_help( + name: str, + leaf_names: list[str], + help: Optional[dict] = None, + stage: Optional[str] = None, +) -> dict: + """Update help and/or stage on a command-tree leaf (command). + + ``leaf_names`` excludes the implicit 'aaz' root, e.g. + ``["consumption", "budget", "create"]``. + """ + manager = WorkspaceManager(name) + manager.load() + leaf = None + if help is not None: + leaf = manager.update_command_tree_leaf_help(*leaf_names, help=help) + if stage is not None: + leaf = manager.update_command_tree_leaf_stage(*leaf_names, stage=stage) + if leaf is None: + raise exceptions.InvalidAPIUsage( + "set_command_help requires at least one of 'help' or 'stage'") + manager.save() + return leaf.to_primitive() + + +def generate_to_aaz(name: str) -> dict: + """Export the workspace's command tree into the aaz repo.""" + manager = WorkspaceManager(name) + manager.load() + manager.generate_to_aaz() + return {"workspace": name, "status": "generated"} + + +def rename_command_group( + name: str, + node_names: list[str], + new_node_names: list[str], +) -> dict: + """Rename a command-tree group (node). + + ``node_names`` and ``new_node_names`` exclude the implicit 'aaz' root. + Example: rename ``['consumption', 'usage-detail']`` to + ``['consumption', 'usage']``. + """ + manager = WorkspaceManager(name) + manager.load() + if not node_names or not new_node_names: + raise exceptions.InvalidAPIUsage( + "node_names and new_node_names must be non-empty") + node = manager.rename_command_tree_node( + *node_names, new_node_names=new_node_names) + manager.save() + return node.to_primitive() if node is not None else { + "from": node_names, "to": new_node_names, "noop": True} + + +def rename_command( + name: str, + leaf_names: list[str], + new_leaf_names: list[str], +) -> dict: + """Rename a command-tree leaf (command). + + ``leaf_names`` and ``new_leaf_names`` exclude the implicit 'aaz' root. + Example: rename ``['consumption', 'pricesheet', 'default', 'show']`` to + ``['consumption', 'pricesheet', 'show']``. + """ + manager = WorkspaceManager(name) + manager.load() + if not leaf_names or not new_leaf_names: + raise exceptions.InvalidAPIUsage( + "leaf_names and new_leaf_names must be non-empty") + leaf = manager.rename_command_tree_leaf( + *leaf_names, new_leaf_names=new_leaf_names) + manager.save() + return leaf.to_primitive() if leaf is not None else { + "from": leaf_names, "to": new_leaf_names, "noop": True} + + +def _ws_summary(manager: WorkspaceManager) -> dict: + result = manager.ws.to_primitive() + result["folder"] = manager.folder + if os.path.exists(manager.path): + result["updated"] = os.path.getmtime(manager.path) + return result diff --git a/src/aaz_dev_mcp/tools/__init__.py b/src/aaz_dev_mcp/tools/__init__.py new file mode 100644 index 00000000..50a5d684 --- /dev/null +++ b/src/aaz_dev_mcp/tools/__init__.py @@ -0,0 +1,20 @@ +"""FastMCP tool registrations. + +Mirrors the ``services/`` layout: every domain submodule exposes a +``register(mcp)`` entry point that wires its ``@mcp.tool()`` callables. +``register_tools`` simply dispatches to each domain in turn. +""" + +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP + +from . import config as _config +from .command import register as _register_command +from .cli import register as _register_cli + + +def register_tools(mcp: FastMCP) -> None: + _config.register(mcp) + _register_command(mcp) + _register_cli(mcp) diff --git a/src/aaz_dev_mcp/tools/cli/__init__.py b/src/aaz_dev_mcp/tools/cli/__init__.py new file mode 100644 index 00000000..02c4dbed --- /dev/null +++ b/src/aaz_dev_mcp/tools/cli/__init__.py @@ -0,0 +1,11 @@ +"""CLI-domain tool registrations.""" + +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP + +from . import modules as _modules + + +def register(mcp: FastMCP) -> None: + _modules.register(mcp) diff --git a/src/aaz_dev_mcp/tools/cli/modules.py b/src/aaz_dev_mcp/tools/cli/modules.py new file mode 100644 index 00000000..5e326cc2 --- /dev/null +++ b/src/aaz_dev_mcp/tools/cli/modules.py @@ -0,0 +1,80 @@ +"""CLI module tools (get/update/select).""" + +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP + + +def register(mcp: FastMCP) -> None: + @mcp.tool() + def get_main_module(module_name: str) -> dict: + """Load the current azure-cli main module's command profile.""" + from ...services.cli import modules + return modules.get_module("main", module_name) + + @mcp.tool() + def get_extension_module(module_name: str) -> dict: + """Load the current azure-cli-extensions module's command profile.""" + from ...services.cli import modules + return modules.get_module("extension", module_name) + + @mcp.tool() + def update_main_module( + module_name: str, + profiles: dict, + by_patch: bool = True, + ) -> dict: + """Generate CLI code for an azure-cli main module from a profiles dict. + + See the `select_command_versions` tool for a higher-level helper that + builds `profiles` from a flat command -> version map. + """ + from ...services.cli import modules + return modules.update_module( + "main", module_name, profiles, by_patch=by_patch) + + @mcp.tool() + def update_extension_module( + module_name: str, + profiles: dict, + by_patch: bool = True, + ) -> dict: + """Generate CLI code for an azure-cli-extensions module from a + profiles dict.""" + from ...services.cli import modules + return modules.update_module( + "extension", module_name, profiles, by_patch=by_patch) + + @mcp.tool() + def select_command_versions( + target: str, + module_name: str, + command_versions: dict, + profile: str = "latest", + by_patch: bool = True, + ) -> dict: + """Pick specific versions for specific commands and generate CLI code. + + - target: 'main' (azure-cli) or 'extension' (azure-cli-extensions) + - module_name: CLI module name, e.g. 'consumption' + - command_versions: map of full command path -> API version. Example:: + + { + "consumption budget create": "2024-08-01", + "consumption budget delete": "2024-08-01" + } + + - profile: CLI profile name, default 'latest' + - by_patch: True (default) merges with existing commands; False + overwrites the whole profile. + + All commands are marked registered=True. + """ + from ...services.cli import modules + return modules.select_command_versions( + target=target, + module_name=module_name, + profile=profile, + command_versions=command_versions, + by_patch=by_patch, + ) diff --git a/src/aaz_dev_mcp/tools/command/__init__.py b/src/aaz_dev_mcp/tools/command/__init__.py new file mode 100644 index 00000000..eb8127c9 --- /dev/null +++ b/src/aaz_dev_mcp/tools/command/__init__.py @@ -0,0 +1,15 @@ +"""Command-domain tool registrations.""" + +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP + +from . import workspaces as _workspaces +from . import arguments as _arguments +from . import examples as _examples + + +def register(mcp: FastMCP) -> None: + _workspaces.register(mcp) + _arguments.register(mcp) + _examples.register(mcp) diff --git a/src/aaz_dev_mcp/tools/command/arguments.py b/src/aaz_dev_mcp/tools/command/arguments.py new file mode 100644 index 00000000..74726710 --- /dev/null +++ b/src/aaz_dev_mcp/tools/command/arguments.py @@ -0,0 +1,89 @@ +"""Argument-editing tools (patch / flatten / unflatten).""" + +from __future__ import annotations + +from typing import Optional + +from mcp.server.fastmcp import FastMCP + + +def register(mcp: FastMCP) -> None: + @mcp.tool() + def update_argument( + name: str, + command: list[str], + arg_var: str, + options: Optional[list[str]] = None, + singular_options: Optional[list[str]] = None, + help: Optional[dict] = None, + stage: Optional[str] = None, + hide: Optional[bool] = None, + group: Optional[str] = None, + clear_group: bool = False, + ) -> dict: + """Patch a single argument on a leaf command. + + Use this to add aliases (e.g. expose --budget-name also as --name / -n) + or to tweak help / stage / group. + + - name: workspace name + - command: full command path without the 'aaz' root, e.g. + ['consumption', 'budget', 'create'] + - arg_var: argument variable, e.g. '$Path.budgetName' + - options: replace the argument's option list, e.g. + ['name', 'n', 'budget-name'] (the first option becomes the + primary --name; subsequent become aliases) + - singular_options: alternative singular form for array / cls args + - help: {'short': str, 'lines': [str], 'refCommands': [str]} + - stage: 'Stable' | 'Preview' | 'Experimental' + - hide: bool (cannot hide required args) + - group: arg group name (non-empty); to remove from a group, + use ``clear_group=True`` instead of passing an empty string. + - clear_group: if True, ungroup the argument (places it in the + default un-named group). Takes precedence over ``group``. + """ + from ...services.command import arguments + return arguments.update_argument( + name=name, command=command, arg_var=arg_var, + options=options, singular_options=singular_options, + help=help, stage=stage, hide=hide, group=group, + clear_group=clear_group) + + @mcp.tool() + def flatten_argument( + name: str, + command: list[str], + arg_var: str, + sub_args_options: Optional[dict[str, list[str]]] = None, + ) -> dict: + """Flatten an object argument into its sub-arguments. + + Example: flatten '$parameters.properties.timePeriod' on + 'consumption budget create' so users get --start-date / --end-date + directly instead of having to pass --time-period as a JSON object. + + - name: workspace name + - command: full command path without the 'aaz' root + - arg_var: object argument variable to flatten + - sub_args_options: optional rename map, e.g. + {'$parameters.properties.timePeriod.startDate': ['start-date']} + """ + from ...services.command import arguments + return arguments.flatten_argument( + name=name, command=command, arg_var=arg_var, + sub_args_options=sub_args_options) + + @mcp.tool() + def unflatten_argument( + name: str, + command: list[str], + arg_var: str, + options: list[str], + help: dict, + sub_args_options: Optional[dict[str, list[str]]] = None, + ) -> dict: + """Inverse of flatten_argument: collapse sub-args back into one object arg.""" + from ...services.command import arguments + return arguments.unflatten_argument( + name=name, command=command, arg_var=arg_var, + options=options, help=help, sub_args_options=sub_args_options) diff --git a/src/aaz_dev_mcp/tools/command/examples.py b/src/aaz_dev_mcp/tools/command/examples.py new file mode 100644 index 00000000..1435e443 --- /dev/null +++ b/src/aaz_dev_mcp/tools/command/examples.py @@ -0,0 +1,72 @@ +"""Command-example tools (list / add / generate-from-swagger / set).""" + +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP + + +def register(mcp: FastMCP) -> None: + @mcp.tool() + def list_command_examples(name: str, command: list[str]) -> list[dict]: + """Return the current examples on a leaf command. + + - name: workspace name + - command: full command path without the 'aaz' root + """ + from ...services.command import examples + return examples.list_command_examples(name=name, command=command) + + @mcp.tool() + def add_command_example( + name: str, + command: list[str], + example_name: str, + commands: list[str], + ) -> list[dict]: + """Append a single manually-authored example to a leaf command. + + Mirrors typing in the editor's "Add Example" dialog. + + - name: workspace name + - command: full command path without the 'aaz' root, e.g. + ['consumption', 'budget', 'create'] + - example_name: short label shown in CLI help + - commands: list of CLI invocation strings, e.g. + ['consumption budget create -n my-budget --amount 100 --category Cost ...'] + + Returns the full updated example list. + """ + from ...services.command import examples + return examples.add_command_example( + name=name, command=command, + example_name=example_name, commands=commands) + + @mcp.tool() + def add_examples_from_swagger( + name: str, + command: list[str], + replace: bool = False, + ) -> list[dict]: + """Generate examples from the OpenAPI spec (the editor's + 'By OpenAPI Specification' button) and persist them on the leaf. + + - replace=False (default): append generated examples to existing ones. + - replace=True: drop existing examples first. + + Returns the full updated example list. + """ + from ...services.command import examples + return examples.add_examples_from_swagger( + name=name, command=command, replace=replace) + + @mcp.tool() + def set_command_examples( + name: str, + command: list[str], + examples: list[dict], + ) -> list[dict]: + """Replace all examples on a leaf command. Each example is + ``{"name": str, "commands": [str, ...]}``. Pass [] to clear.""" + from ...services.command import examples as cmd_examples + return cmd_examples.set_command_examples( + name=name, command=command, examples=examples) diff --git a/src/aaz_dev_mcp/tools/command/workspaces.py b/src/aaz_dev_mcp/tools/command/workspaces.py new file mode 100644 index 00000000..e5550ef1 --- /dev/null +++ b/src/aaz_dev_mcp/tools/command/workspaces.py @@ -0,0 +1,158 @@ +"""Workspace lifecycle + command-tree edit tools.""" + +from __future__ import annotations + +from typing import Optional + +from mcp.server.fastmcp import FastMCP + + +def register(mcp: FastMCP) -> None: + @mcp.tool() + def list_workspaces() -> list[dict]: + """List existing workspaces in the configured workspace folder.""" + from ...services.command import workspaces + return workspaces.list_workspaces() + + @mcp.tool() + def create_workspace( + name: str, + mod_names: str, + resource_provider: str, + plane: str = "mgmt-plane", + source: str = "OpenAPI", + ) -> dict: + """Create a new workspace. Errors if a workspace with this name + already exists. + + - name: workspace name (becomes the folder name on disk) + - mod_names: swagger module path under specification/, e.g. + 'consumption' or 'compute/Microsoft.Compute' + - resource_provider: the ARM RP name, e.g. 'Microsoft.Consumption' + - plane: 'mgmt-plane' (default) or 'data-plane' + - source: 'OpenAPI' (default) or 'TypeSpec' + """ + from ...services.command import workspaces + return workspaces.create_workspace( + name=name, + plane=plane, + mod_names=mod_names, + resource_provider=resource_provider, + source=source, + ) + + @mcp.tool() + def load_workspace(name: str) -> dict: + """Load an existing workspace and return its command tree. + + Returns the workspace's command tree as a dict. + """ + from ...services.command import workspaces + return workspaces.load_workspace(name) + + @mcp.tool() + def add_swagger_resources( + name: str, + module: str, + version: str, + resource_paths: list[str], + ) -> dict: + """Add Azure REST API swagger resources to a workspace. + + The resource_paths are the raw URL templates from the swagger spec, + e.g. '/subscriptions/{subscriptionId}/providers/Microsoft.Consumption/budgets'. + They are normalized to resource ids automatically. + + - name: workspace name + - module: swagger module (e.g. 'consumption') + - version: API version (e.g. '2024-08-01') + - resource_paths: list of swagger paths + """ + from ...services.command import workspaces + return workspaces.add_swagger_resources( + name=name, + module=module, + version=version, + resource_paths=resource_paths, + ) + + @mcp.tool() + def set_node_help( + name: str, + node_names: list[str], + help: Optional[dict] = None, + stage: Optional[str] = None, + ) -> dict: + """Update help text and/or stage on a command-group node. + + - name: workspace name + - node_names: path to the group, e.g. ['consumption', 'budget'] + (do NOT include the implicit 'aaz' root) + - help: dict with 'short' and optional 'lines'; e.g. + {'short': 'Manage consumption budgets.'} + - stage: one of 'Stable', 'Preview', 'Experimental' + """ + from ...services.command import workspaces + return workspaces.set_node_help( + name=name, node_names=node_names, help=help, stage=stage) + + @mcp.tool() + def set_command_help( + name: str, + leaf_names: list[str], + help: Optional[dict] = None, + stage: Optional[str] = None, + ) -> dict: + """Update help text and/or stage on a leaf command. + + - name: workspace name + - leaf_names: full command path, e.g. ['consumption', 'budget', 'create'] + (do NOT include the implicit 'aaz' root) + - help: dict with 'short' and optional 'lines' + - stage: one of 'Stable', 'Preview', 'Experimental' + """ + from ...services.command import workspaces + return workspaces.set_command_help( + name=name, leaf_names=leaf_names, help=help, stage=stage) + + @mcp.tool() + def generate_to_aaz(name: str) -> dict: + """Export the workspace's command tree into the configured aaz repo + (writes JSON/XML under the Commands/ tree).""" + from ...services.command import workspaces + return workspaces.generate_to_aaz(name) + + @mcp.tool() + def rename_command_group( + name: str, + node_names: list[str], + new_node_names: list[str], + ) -> dict: + """Rename a command-tree group (e.g. rename 'consumption usage-detail' + to 'consumption usage'). Both lists exclude the implicit 'aaz' root. + + - name: workspace name + - node_names: current group path, e.g. ['consumption', 'usage-detail'] + - new_node_names: target path, e.g. ['consumption', 'usage'] + """ + from ...services.command import workspaces + return workspaces.rename_command_group( + name=name, node_names=node_names, new_node_names=new_node_names) + + @mcp.tool() + def rename_command( + name: str, + leaf_names: list[str], + new_leaf_names: list[str], + ) -> dict: + """Rename a command-tree leaf (e.g. rename + 'consumption pricesheet default show' to 'consumption pricesheet show'). + Both lists exclude the implicit 'aaz' root. + + - name: workspace name + - leaf_names: current command path + - new_leaf_names: target command path + """ + from ...services.command import workspaces + return workspaces.rename_command( + name=name, leaf_names=leaf_names, new_leaf_names=new_leaf_names) diff --git a/src/aaz_dev_mcp/tools/config.py b/src/aaz_dev_mcp/tools/config.py new file mode 100644 index 00000000..d7533b46 --- /dev/null +++ b/src/aaz_dev_mcp/tools/config.py @@ -0,0 +1,42 @@ +"""``configure`` tool — wires repo paths used by aaz-dev-tools.""" + +from __future__ import annotations + +from typing import Optional + +from mcp.server.fastmcp import FastMCP + + +def register(mcp: FastMCP) -> None: + @mcp.tool() + def configure( + 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: + """Configure repository paths used by aaz-dev-tools. + + All arguments are optional; only the ones provided are updated. Pass + no arguments to inspect the current values. Paths are validated for + existence; invalid paths raise an error. + + - aaz_path: clone of github.com/Azure/aaz + - swagger_path: clone of github.com/Azure/azure-rest-api-specs + - cli_path: clone of github.com/Azure/azure-cli + - cli_extension_path: clone of github.com/Azure/azure-cli-extensions + - workspace_folder: where ws.json files are stored + (default ~/.aaz/workspaces) + """ + from ..config import apply_config + try: + return apply_config( + aaz_path=aaz_path, + swagger_path=swagger_path, + cli_path=cli_path, + cli_extension_path=cli_extension_path, + workspace_folder=workspace_folder, + ) + except ValueError as e: + return {"error": str(e)}