diff --git a/CODED-MCP-ACI-SPIKE.md b/CODED-MCP-ACI-SPIKE.md new file mode 100644 index 000000000..8fb8d7b72 --- /dev/null +++ b/CODED-MCP-ACI-SPIKE.md @@ -0,0 +1,17 @@ +# Coded MCP on ACI — spike branch pointer + +Branch **`spike/coded-mcp-aci-runtime`** is part of a **cross-repo spike/POC** that makes coded MCP +servers run as Azure Container Instances orchestrated by AgentHub (start faster / stay alive longer). + +**This repo's slice:** the CLI `uipath image build` / `uipath image publish` commands +(`packages/uipath/src/uipath/_cli/cli_image.py`) — package a coded MCP project into a container +image and push it to ACR. + +**Full handoff, docs, architecture, and how to resume on another machine** live in the +**`UiPath/AgentHubService`** repo on the **same branch**: +- `docs/coded-mcp-aci-HANDOFF.md` — **start here** +- `docs/coded-mcp-aci-poc-guide.md` — how it works + how to run +- `docs/coded-mcp-aci-road-to-production.md`, `docs/coded-mcp-aci-cost-analysis.md` + +**Companion branches (same name `spike/coded-mcp-aci-runtime`):** `UiPath/AgentHubService`, +`UiPath/uipath-python`, `UiPath/uipath-mcp-python`. diff --git a/packages/uipath/src/uipath/_cli/__init__.py b/packages/uipath/src/uipath/_cli/__init__.py index d8d3a8a46..b93b4e4fe 100644 --- a/packages/uipath/src/uipath/_cli/__init__.py +++ b/packages/uipath/src/uipath/_cli/__init__.py @@ -46,6 +46,7 @@ "register": "cli_register", "debug": "cli_debug", "list-models": "cli_list_models", + "image": "cli_image", "assets": "services.cli_assets", "buckets": "services.cli_buckets", "context-grounding": "services.cli_context_grounding", diff --git a/packages/uipath/src/uipath/_cli/cli_image.py b/packages/uipath/src/uipath/_cli/cli_image.py new file mode 100644 index 000000000..ac5f308ee --- /dev/null +++ b/packages/uipath/src/uipath/_cli/cli_image.py @@ -0,0 +1,213 @@ +import json +import re +import subprocess +from pathlib import Path +from typing import Any + +import click + +from uipath._cli._utils._console import ConsoleLogger +from uipath._cli._utils._project_files import read_toml_project + +from ._telemetry import track_command + +console = ConsoleLogger() + +CONTAINER_MANIFEST_SCHEMA = "https://cloud.uipath.com/draft/2026-06/container-image" +DEFAULT_OUTPUT_DIR = ".uipath/image" +DEFAULT_PYTHON_VERSION = "3.11" + + +@click.group() +def image() -> None: + """Build and publish UiPath container image artifacts.""" + + +@image.command(name="build") +@click.argument( + "root", + required=False, + default=".", + type=click.Path(exists=True, file_okay=False, dir_okay=True), +) +@click.option("--tag", "image_tag", type=str, help="Container image tag to build.") +@click.option( + "--entrypoint", + "entrypoint", + type=str, + help="mcp.json server name to run (required if multiple servers).", +) +@click.option( + "--output-dir", + default=DEFAULT_OUTPUT_DIR, + show_default=True, + type=click.Path(file_okay=False), + help="Directory for generated artifacts.", +) +@click.option("--base-image", type=str, help="Base image for the generated Dockerfile.") +@click.option( + "--dry-run", + is_flag=True, + help="Generate artifacts and print the docker command without running Docker.", +) +@track_command("image-build") +def build( + root: str, + image_tag: str | None, + entrypoint: str | None, + output_dir: str, + base_image: str | None, + dry_run: bool, +) -> None: + """Build a Docker image for a coded UiPath MCP project.""" + project_root = Path(root).resolve() + out = ( + (project_root / output_dir).resolve() + if not Path(output_dir).is_absolute() + else Path(output_dir) + ) + out.mkdir(parents=True, exist_ok=True) + + project = read_toml_project(str(project_root / "pyproject.toml")) + server = _resolve_entrypoint(project_root, entrypoint) + py = _python_version(project.get("requires-python")) + base_image = base_image or f"ghcr.io/astral-sh/uv:python{py}-bookworm-slim" + image_tag = ( + image_tag or f"uipath/{_safe(project['name'])}:{_safe(project['version'])}" + ) + + (out / "Dockerfile").write_text(_dockerfile(base_image, server), encoding="utf-8") + (out / ".dockerignore").write_text( + _dockerignore(out, project_root), encoding="utf-8" + ) + (out / "container-manifest.json").write_text( + json.dumps(_manifest(image_tag, base_image, project, server), indent=2) + "\n", + encoding="utf-8", + ) + + cmd = [ + "docker", + "build", + "-f", + str(out / "Dockerfile"), + "-t", + image_tag, + str(project_root), + ] + console.success(f"Generated image artifacts in {out}") + if dry_run: + click.echo(" ".join(cmd)) + return + subprocess.run(cmd, check=True) + + +@image.command(name="publish") +@click.option( + "--registry", + required=True, + help="ACR login server, e.g. myacr.azurecr.io", +) +@click.option( + "--tag", + "image_tag", + required=True, + help="Local image tag to push.", +) +@track_command("image-publish") +def publish(registry: str, image_tag: str) -> None: + """Push a built image to an Azure Container Registry.""" + acr_name = registry.split(".")[0] + target = f"{registry}/{image_tag}" + subprocess.run(["az", "acr", "login", "--name", acr_name], check=True) + subprocess.run(["docker", "tag", image_tag, target], check=True) + subprocess.run(["docker", "push", target], check=True) + console.success(f"Pushed {target}") + + +def _resolve_entrypoint(project_root: Path, override: str | None) -> str: + """Return the mcp.json server name to run (the AgentHub slug).""" + if override: + return override + mcp = project_root / "mcp.json" + if not mcp.exists(): + console.error("mcp.json not found; run `uipath init` for an MCP project.") + servers = json.loads(mcp.read_text(encoding="utf-8")).get("servers", {}) + names = list(servers.keys()) + if len(names) != 1: + console.error( + f"Expected exactly one server in mcp.json, found {names}; pass --entrypoint." + ) + return names[0] + + +def _python_version(requires_python: str | None) -> str: + """Extract Python version string from requires-python specifier.""" + if not requires_python: + return DEFAULT_PYTHON_VERSION + m = re.search(r"(3)\.(\d+)", requires_python) + return f"{m.group(1)}.{m.group(2)}" if m else DEFAULT_PYTHON_VERSION + + +def _safe(value: str) -> str: + """Normalize a string to a safe Docker tag component.""" + return re.sub(r"[^a-z0-9._-]+", "-", value.lower()).strip(".-_") or "project" + + +def _dockerfile(base_image: str, server: str) -> str: + """Generate Dockerfile content for an MCP project.""" + return "\n".join( + [ + f"FROM {base_image}", + "", + "WORKDIR /app", + "ENV PYTHONUNBUFFERED=1 \\", + " UV_LINK_MODE=copy \\", + " UV_COMPILE_BYTECODE=1 \\", + ' PATH="/app/.venv/bin:$PATH"', + "", + "COPY . .", + "RUN if [ -f uv.lock ]; then uv sync --frozen --no-dev; else uv sync --no-dev; fi", + "", + 'ENTRYPOINT ["uipath", "run"]', + f'CMD ["{server}"]', + "", + ] + ) + + +def _dockerignore(out: Path, root: Path) -> str: + """Generate .dockerignore content, excluding the output dir if inside root.""" + rel = out.relative_to(root).as_posix() if out.is_relative_to(root) else "" + entries = [ + ".git", + ".venv", + "__pycache__/", + ".pytest_cache/", + ".ruff_cache/", + ".mypy_cache/", + "*.pyc", + ] + if rel: + entries.append(rel) + return "\n".join(entries) + "\n" + + +def _manifest( + image_tag: str, base_image: str, project: dict[str, Any], server: str +) -> dict[str, Any]: + """Generate container-manifest.json content.""" + return { + "$schema": CONTAINER_MANIFEST_SCHEMA, + "image": image_tag, + "baseImage": base_image, + "projectName": project["name"], + "version": project["version"], + "targetRuntime": "python", + "defaultEntrypoint": server, + "command": ["uipath", "run", server], + "labels": { + "com.uipath.project.name": project["name"], + "com.uipath.project.version": project["version"], + "com.uipath.artifact.kind": "coded-mcp-container", + }, + } diff --git a/packages/uipath/tests/cli/test_image.py b/packages/uipath/tests/cli/test_image.py new file mode 100644 index 000000000..786139ef4 --- /dev/null +++ b/packages/uipath/tests/cli/test_image.py @@ -0,0 +1,135 @@ +import json +import shutil +import subprocess +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from uipath._cli import cli + + +def _docker_available() -> bool: + if shutil.which("docker") is None: + return False + try: + return ( + subprocess.run( + ["docker", "info"], capture_output=True, timeout=5 + ).returncode + == 0 + ) + except (OSError, subprocess.TimeoutExpired): + return False + + +docker_available = _docker_available() + + +def _scaffold(tmp: Path) -> None: + (tmp / "pyproject.toml").write_text( + '[project]\nname = "invoice-mcp"\nversion = "1.2.3"\n' + 'description = "Invoice MCP server"\n' + 'requires-python = ">=3.12"\ndependencies = ["uipath-mcp>=0.1.0"]\n', + encoding="utf-8", + ) + # mcp.json server name is the authoritative slug / run argument + (tmp / "mcp.json").write_text( + json.dumps( + {"servers": {"invoice": {"command": "python", "args": ["server.py"]}}} + ), + encoding="utf-8", + ) + (tmp / "server.py").write_text("# server\n", encoding="utf-8") + + +def test_image_build_dry_run_generates_artifacts( + runner: CliRunner, temp_dir: str +) -> None: + with runner.isolated_filesystem(temp_dir=temp_dir): + _scaffold(Path(".")) + result = runner.invoke(cli, ["image", "build", "--dry-run"], env={}) + assert result.exit_code == 0, result.output + assert "docker build" in result.output + + out = Path(".uipath/image") + dockerfile = (out / "Dockerfile").read_text(encoding="utf-8") + manifest = json.loads( + (out / "container-manifest.json").read_text(encoding="utf-8") + ) + + assert "FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim" in dockerfile + assert 'ENTRYPOINT ["uipath", "run"]' in dockerfile + # entrypoint is the mcp.json server name, NOT a file path + assert 'CMD ["invoice"]' in dockerfile + assert manifest["command"] == ["uipath", "run", "invoice"] + assert manifest["defaultEntrypoint"] == "invoice" + assert manifest["image"] == "uipath/invoice-mcp:1.2.3" + + dockerignore = (out / ".dockerignore").read_text(encoding="utf-8") + assert ".git" in dockerignore + assert ".venv" in dockerignore + assert ( + ".uipath/image" in dockerignore + ) # output dir excluded from its own build context + + +@pytest.mark.skipif(not docker_available, reason="docker not available") +def test_image_build_produces_runnable_image(runner: CliRunner, temp_dir: str) -> None: + """Build the image and verify uipath CLI is on PATH (so 'uipath run' works).""" + with runner.isolated_filesystem(temp_dir=temp_dir): + _scaffold(Path(".")) + try: + # Build the real image (no --dry-run); network required for uv sync + result = runner.invoke( + cli, ["image", "build", "--tag", "uipath-itest/invoice:0"], env={} + ) + assert result.exit_code == 0, result.output + # The image must expose the uipath CLI on PATH + docker_run = subprocess.run( + [ + "docker", + "run", + "--rm", + "--entrypoint", + "uipath", + "uipath-itest/invoice:0", + "--help", + ], + capture_output=True, + text=True, + ) + assert docker_run.returncode == 0, docker_run.stderr + assert "run" in docker_run.stdout + finally: + subprocess.run( + ["docker", "rmi", "-f", "uipath-itest/invoice:0"], + check=False, + capture_output=True, + ) + + +def test_image_publish_constructs_push( + runner: CliRunner, temp_dir: str, monkeypatch +) -> None: + calls = [] + monkeypatch.setattr( + "uipath._cli.cli_image.subprocess.run", + lambda cmd, **kw: calls.append(cmd) or subprocess.CompletedProcess(cmd, 0), + ) + with runner.isolated_filesystem(temp_dir=temp_dir): + _scaffold(Path(".")) + runner.invoke( + cli, ["image", "build", "--tag", "invoice:0", "--dry-run"], env={} + ) + result = runner.invoke( + cli, + ["image", "publish", "--registry", "ah.azurecr.io", "--tag", "invoice:0"], + env={}, + ) + assert result.exit_code == 0, result.output + assert ["az", "acr", "login", "--name", "ah"] in calls + assert any( + c[:2] == ["docker", "push"] and c[-1] == "ah.azurecr.io/invoice:0" + for c in calls + )