From ddc94f3d3506add5397ce8afa2d54e6be6ab69f0 Mon Sep 17 00:00:00 2001 From: Robert Ursu Date: Tue, 30 Jun 2026 09:42:04 +0300 Subject: [PATCH 1/6] feat(cli): uipath image build generates Dockerfile + manifest (entrypoint == mcp.json server) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RtWMqFqR3rGt1mTme23ShF --- packages/uipath/src/uipath/_cli/__init__.py | 1 + packages/uipath/src/uipath/_cli/cli_image.py | 190 +++++++++++++++++++ packages/uipath/tests/cli/test_image.py | 47 +++++ 3 files changed, 238 insertions(+) create mode 100644 packages/uipath/src/uipath/_cli/cli_image.py create mode 100644 packages/uipath/tests/cli/test_image.py 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..70bd09786 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/cli_image.py @@ -0,0 +1,190 @@ +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) + + +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..dafcbd783 --- /dev/null +++ b/packages/uipath/tests/cli/test_image.py @@ -0,0 +1,47 @@ +import json +from pathlib import Path + +from click.testing import CliRunner + +from uipath._cli import cli + + +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" From 5b29e7bf3bae3a73e8fca08e7d91d91199d1d3fc Mon Sep 17 00:00:00 2001 From: Robert Ursu Date: Tue, 30 Jun 2026 09:46:18 +0300 Subject: [PATCH 2/6] test(cli): assert generated .dockerignore contents --- packages/uipath/tests/cli/test_image.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/uipath/tests/cli/test_image.py b/packages/uipath/tests/cli/test_image.py index dafcbd783..9a504eb29 100644 --- a/packages/uipath/tests/cli/test_image.py +++ b/packages/uipath/tests/cli/test_image.py @@ -45,3 +45,10 @@ def test_image_build_dry_run_generates_artifacts( 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 From a670ddce6e6272252c2b787eadb4d767ccc6a028 Mon Sep 17 00:00:00 2001 From: Robert Ursu Date: Tue, 30 Jun 2026 09:50:05 +0300 Subject: [PATCH 3/6] test(cli): verify built image exposes uipath run; fix Dockerfile gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Docker-gated integration test that builds the generated image from a scaffolded mcp project and verifies the uipath CLI is on PATH inside the container (uipath --help lists run). No Dockerfile changes needed — uipath is installed transitively via uipath-mcp's dependency chain. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RtWMqFqR3rGt1mTme23ShF --- packages/uipath/tests/cli/test_image.py | 36 +++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/uipath/tests/cli/test_image.py b/packages/uipath/tests/cli/test_image.py index 9a504eb29..598ca4ad7 100644 --- a/packages/uipath/tests/cli/test_image.py +++ b/packages/uipath/tests/cli/test_image.py @@ -1,10 +1,18 @@ import json +import shutil +import subprocess from pathlib import Path +import pytest from click.testing import CliRunner from uipath._cli import cli +docker_available = ( + shutil.which("docker") is not None + and subprocess.run(["docker", "info"], capture_output=True).returncode == 0 +) + def _scaffold(tmp: Path) -> None: (tmp / "pyproject.toml").write_text( @@ -52,3 +60,31 @@ def test_image_build_dry_run_generates_artifacts( 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(".")) + # 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 + inspect = subprocess.run( + [ + "docker", + "run", + "--rm", + "--entrypoint", + "uipath", + "uipath-itest/invoice:0", + "--help", + ], + capture_output=True, + text=True, + ) + assert inspect.returncode == 0, inspect.stderr + assert "run" in inspect.stdout From 076052fe68a8b955eb9cbb4700385989415be67f Mon Sep 17 00:00:00 2001 From: Robert Ursu Date: Tue, 30 Jun 2026 09:52:53 +0300 Subject: [PATCH 4/6] test(cli): robust docker probe, image cleanup, rename shadowed var --- packages/uipath/tests/cli/test_image.py | 69 ++++++++++++++++--------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/packages/uipath/tests/cli/test_image.py b/packages/uipath/tests/cli/test_image.py index 598ca4ad7..dc374b865 100644 --- a/packages/uipath/tests/cli/test_image.py +++ b/packages/uipath/tests/cli/test_image.py @@ -8,10 +8,22 @@ from uipath._cli import cli -docker_available = ( - shutil.which("docker") is not None - and subprocess.run(["docker", "info"], capture_output=True).returncode == 0 -) + +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: @@ -67,24 +79,31 @@ def test_image_build_produces_runnable_image(runner: CliRunner, temp_dir: str) - """Build the image and verify uipath CLI is on PATH (so 'uipath run' works).""" with runner.isolated_filesystem(temp_dir=temp_dir): _scaffold(Path(".")) - # 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 - inspect = subprocess.run( - [ - "docker", - "run", - "--rm", - "--entrypoint", - "uipath", - "uipath-itest/invoice:0", - "--help", - ], - capture_output=True, - text=True, - ) - assert inspect.returncode == 0, inspect.stderr - assert "run" in inspect.stdout + 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, + ) From da3b9b4d1f9772faa0b94a31d12dad0ed994e49d Mon Sep 17 00:00:00 2001 From: Robert Ursu Date: Tue, 30 Jun 2026 09:54:41 +0300 Subject: [PATCH 5/6] feat(cli): uipath image publish pushes to ACR Adds publish subcommand to image group that: - Takes --registry (ACR login server) and --tag (local image tag) options - Runs az acr login to authenticate with the registry - Tags the local image with the registry/tag combination - Pushes the tagged image to ACR - Prints success message with pushed reference Includes test with mocked subprocess to verify correct az/docker commands. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RtWMqFqR3rGt1mTme23ShF --- packages/uipath/src/uipath/_cli/cli_image.py | 23 +++++++++++++++++ packages/uipath/tests/cli/test_image.py | 26 ++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/packages/uipath/src/uipath/_cli/cli_image.py b/packages/uipath/src/uipath/_cli/cli_image.py index 70bd09786..ac5f308ee 100644 --- a/packages/uipath/src/uipath/_cli/cli_image.py +++ b/packages/uipath/src/uipath/_cli/cli_image.py @@ -101,6 +101,29 @@ def build( 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: diff --git a/packages/uipath/tests/cli/test_image.py b/packages/uipath/tests/cli/test_image.py index dc374b865..786139ef4 100644 --- a/packages/uipath/tests/cli/test_image.py +++ b/packages/uipath/tests/cli/test_image.py @@ -107,3 +107,29 @@ def test_image_build_produces_runnable_image(runner: CliRunner, temp_dir: str) - 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 + ) From 5477cca98c5b344881a293044e9aafbe9a52d432 Mon Sep 17 00:00:00 2001 From: Robert Ursu Date: Thu, 2 Jul 2026 14:06:20 +0300 Subject: [PATCH 6/6] docs: pointer to the coded-MCP-on-ACI spike handoff This branch is part of the cross-repo coded-MCP-on-ACI spike/POC. Full handoff, docs, and how-to-run live in UiPath/AgentHubService on the same branch (docs/coded-mcp-aci-HANDOFF.md). This file just cross-links from here. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RtWMqFqR3rGt1mTme23ShF --- CODED-MCP-ACI-SPIKE.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 CODED-MCP-ACI-SPIKE.md 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`.