Blog Post

Azure Infrastructure Blog
8 MIN READ

Extending Azure VM Image Automation with an MCP Operations Layer

Prakhar-Sharma's avatar
Nov 04, 2026

Part 2 of the Azure VM image automation series: add a focused MCP server that lets engineers query Azure DevOps image-pipeline runs, check release evidence and safely queue builds from GitHub Copilot Chat in Visual Studio Code.

Introduction

In Part 1, we built a repeatable Azure VM image pipeline with Packer, Azure DevOps and Azure Compute Gallery, then validated the published image before promotion. Read Part 1

This second part adds a focused MCP operations layer. The build logic remains in Azure DevOps; the server exposes controlled tools for build status, validation evidence and optional pipeline queueing.

The essential implementation is included directly in this article so the reader can create the local project, run it and understand each moving part without a separate code download.

Solution at a glance: MCP host → Image Pipeline MCP server → policy boundary → Azure DevOps REST APIs → build, validation and promotion evidence

What this article demonstrates

  • Read recent image-pipeline runs and correlated build metadata.
  • Run the server, invoke each tool and interpret the result.
  • Keep write actions disabled by default and require explicit confirmation.
  • Use the same tools from Visual Studio Code Copilot Chat.

Where MCP fits

The MCP server is a thin operations interface in front of the delivery system from Part 1. It does not replace Packer, Azure Pipelines, Azure Compute Gallery or Azure DevOps approval controls.

Figure 1. MCP operations layer for image-pipeline queries and guarded execution.

Design principle: Expose domain outcomes such as “show missing validation evidence”, not a broad unrestricted HTTP proxy.

Prerequisites

  • Visual Studio Code with GitHub Copilot Chat.
  • Python 3.10 or later.
  • uv for Python environment and dependency management.
  • Node.js with npx available for MCP Inspector.
  • An Azure DevOps organisation, project and image pipeline based on Part 1.
  • Read access to pipeline runs and artefacts; queue permission only for guarded-write testing.

Build and configure the sample

1. Create the local sample project

Create a folder named azure-image-pipeline-mcp and add the following files. The sample keeps the MCP layer in one Python file so the complete flow remains visible in the article.

Project structure

azure-image-pipeline-mcp/
├── .env
├── .vscode/
│   └── mcp.json
├── pyproject.toml
└── server.py

pyproject.toml

[project]
name = "azure-image-pipeline-mcp"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
  "mcp[cli]>=2.2,<3",
  "httpx>=0.27,<1"
]

[project.scripts]
azure-image-pipeline-mcp = "server:main"

2. Install dependencies

Create the environment

uv --version
uv sync

3. Create .env

macOS or Linux

touch .env

Windows PowerShell

New-Item -ItemType File .env

Create .env in the project root. It holds a credential, so never commit it; if you use Git, add .env to .gitignore.

4. Configure Azure DevOps

Initial configuration

AZDO_ORGANIZATION=https://dev.azure.com/your-organisation
AZDO_PROJECT=your-project
AZDO_PIPELINE_ID=42
AZDO_PAT=your-local-development-token
MCP_ALLOW_QUEUE_RUN=false
MCP_ALLOWED_PARAMETERS=runtimeVersion,imageFlavor,enableSecurityScan,location,imageVersion
  • AZDO_ORGANIZATION is the organisation URL.
  • AZDO_PROJECT contains the image pipeline.
  • AZDO_PIPELINE_ID is the numeric ID shown in the pipeline URL.
  • AZDO_PAT is for compact local development only; keep its scope minimal and never commit it.
  • MCP_ALLOW_QUEUE_RUN=false keeps write behaviour blocked.
  • MCP_ALLOWED_PARAMETERS defines the accepted pipeline template parameters.

5. Add the MCP server implementation

The server exposes three read-only tools and one guarded write tool. Azure DevOps remains responsible for the actual image build, validation and promotion.

server.py: configuration and Azure DevOps client

import base64
import os
from urllib.parse import quote

import httpx
from mcp.server import MCPServer

mcp = MCPServer("Azure Image Pipeline MCP")
ORG = os.environ["AZDO_ORGANIZATION"].rstrip("/")
PROJECT = quote(os.environ["AZDO_PROJECT"], safe="")
PIPELINE_ID = int(os.environ["AZDO_PIPELINE_ID"])
PAT = os.environ["AZDO_PAT"]
ALLOW_QUEUE = os.getenv("MCP_ALLOW_QUEUE_RUN", "false").lower() == "true"
ALLOWED = {x.strip() for x in os.getenv(
    "MCP_ALLOWED_PARAMETERS",
    "runtimeVersion,imageFlavor,enableSecurityScan,location,imageVersion"
).split(",") if x.strip()}
BASE = f"{ORG}/{PROJECT}/_apis"

async def azdo(method: str, path: str, **kwargs):
    token = base64.b64encode(f":{PAT}".encode()).decode()
    headers = {"Authorization": f"Basic {token}", "Content-Type": "application/json"}
    async with httpx.AsyncClient(headers=headers, timeout=30) as client:
        response = await client.request(method, f"{BASE}/{path}", **kwargs)
        response.raise_for_status()
        return response.json()

server.py: read-only tools

@mcp.tool()
async def list_recent_image_runs(top: int = 10) -> dict:
    data = await azdo(
        "GET", f"pipelines/{PIPELINE_ID}/runs",
        params={"api-version": "7.1", "$top": min(max(top, 1), 50)}
    )
    return {"pipelineId": PIPELINE_ID, "runs": data.get("value", [])}

@mcp.tool()
async def get_image_run_status(run_id: int) -> dict:
    run = await azdo(
        "GET", f"pipelines/{PIPELINE_ID}/runs/{run_id}",
        params={"api-version": "7.1"}
    )
    build = await azdo(
        "GET", f"build/builds/{run_id}",
        params={"api-version": "7.1"}
    )
    return {"run": run, "build": build}

@mcp.tool()
async def get_image_run_evidence(run_id: int) -> dict:
    data = await azdo(
        "GET", f"build/builds/{run_id}/artifacts",
        params={"api-version": "7.1"}
    )
    artifacts = data.get("value", [])
    expected = {"build-summary", "image-validation", "security-scan", "promotion-record"}
    found = {item.get("name") for item in artifacts}
    return {"runId": run_id, "artifacts": artifacts, "missing": sorted(expected - found)}

server.py: guarded queue tool and entry point

@mcp.tool()
async def queue_image_pipeline(
    confirmation: str,
    runtime_version: str,
    image_flavour: str,
    location: str,
    enable_security_scan: bool = True,
    branch: str = "refs/heads/main",
) -> dict:
    if not ALLOW_QUEUE:
        raise PermissionError("Queueing is disabled")
    if confirmation != "QUEUE_IMAGE_PIPELINE":
        raise PermissionError("Explicit confirmation is required")

    parameters = {
        "runtimeVersion": runtime_version,
        "imageFlavor": image_flavour,
        "location": location,
        "enableSecurityScan": enable_security_scan,
    }
    unknown = set(parameters) - ALLOWED
    if unknown:
        raise ValueError(f"Unsupported parameters: {sorted(unknown)}")

    body = {
        "resources": {"repositories": {"self": {"refName": branch}}},
        "templateParameters": parameters,
    }
    run = await azdo(
        "POST", f"pipelines/{PIPELINE_ID}/runs",
        params={"api-version": "7.1"}, json=body
    )
    return {"queued": True, "run": run, "parameters": parameters}

def main():
    mcp.run()

if __name__ == "__main__":
    main()

6. Validate the server locally

Static validation

python -m compileall -q server.py
uv run python -c "import server; print('MCP server imports successfully')"

Run the commands above from the project root. Successful completion confirms that server.py compiles and imports with the installed dependencies. The Inspector checks in the next section validate tool discovery and invocation.

Validation scope: These static checks confirm that server.py compiles and imports. A live integration check still requires a valid Azure DevOps organisation, project, pipeline ID and credential.

7. Run the MCP server and try the tools

Use MCP Inspector for a direct first test of the Python server. Inspector connects to the local server, discovers its registered tools and lets you call each tool with sample inputs before using the tools through Copilot Chat.

Start the server from the project root

uv run mcp dev server.py

The command prints a local Inspector URL. Open that URL, select Tools, and confirm these four tools are available:

  • list_recent_image_runs
  • get_image_run_status
  • get_image_run_evidence
  • queue_image_pipeline

Sample 1: list recent runs

Tool and input

Tool: list_recent_image_runs
Input:
{
  "top": 10
}

What it does: queries the configured Azure DevOps image pipeline and returns recent run IDs, names, states, results, dates and links.

Sample 2: inspect one run

Tool and input

Tool: get_image_run_status
Input:
{
  "run_id": 1234
}

Replace 1234 with a real run ID returned by the first tool. The response correlates the pipeline run with build number, result, branch and source commit.

Sample 3: check release evidence

Tool and input

Tool: get_image_run_evidence
Input:
{
  "run_id": 1234
}

The server lists published artefacts and reports expected evidence that is missing, such as image validation, security scan, promotion record or build summary.

Sample 4: confirm the write guard

Safe negative test

Tool: queue_image_pipeline
Input:
{
  "confirmation": "QUEUE_IMAGE_PIPELINE",
  "runtime_version": "21",
  "image_flavour": "cis",
  "location": "uksouth"
}

With MCP_ALLOW_QUEUE_RUN=false, the request must be rejected with “Queueing is disabled”. This confirms the fail-closed default without starting a pipeline.

Live queue test

Only in an approved development or test project, set MCP_ALLOW_QUEUE_RUN=true, restart the server and repeat the call. The exact confirmation value and allow-listed parameters are still required. Azure DevOps approvals and permissions remain authoritative.

After the four checks succeed, stop the Inspector process with Ctrl+C. Visual Studio Code starts the same server itself through .vscode/mcp.json for normal Copilot Chat use.

Use the MCP server from Visual Studio Code

Visual Studio Code recognises the server from .vscode/mcp.json. The Python source does not register itself. When the project root is the workspace, Visual Studio Code reads the configuration, starts the stdio command, loads .env and performs MCP tool discovery.

Discovery and start sequence

Open the project root in Visual Studio Code
        |
        v
Visual Studio Code reads .vscode/mcp.json
        |
        v
Visual Studio Code starts: uv run python server.py
        |
        v
The process loads ${workspaceFolder}/.env
        |
        v
The MCP handshake discovers the four tools
        |
        v
Tools appear in Copilot Chat > Agent > Configure Tools

Important: Run code . from the azure-image-pipeline-mcp project root. If the parent directory is opened instead, ${workspaceFolder} can point to the wrong location.

Workspace-level .vscode/mcp.json

{
  "servers": {
    "azure-image-pipeline": {
      "type": "stdio",
      "command": "uv",
      "args": ["run", "python", "server.py"],
      "cwd": "${workspaceFolder}",
      "envFile": "${workspaceFolder}/.env"
    }
  }
}

Start the server and use it in Copilot Chat

  1. Open .vscode/mcp.json and select the inline Start action for azure-image-pipeline.
  2. Review the command, approve the trusted local server and confirm its status is Running.
  3. Open Copilot Chat, select Agent, then Configure Tools, and enable the four azure-image-pipeline tools.

Try these prompts:

  • Show the latest ten image pipeline runs.
  • Show the status, branch and source commit for run 1234.
  • Which release evidence is missing from run 1234?
  • Prepare a Java 21 CIS image build in UK South and show the parameters before requesting confirmation.

Troubleshooting

SymptomCheck
uv is not recognisedRestart the terminal and verify PATH.
Inspector does not startConfirm Node.js and npx are available.
Missing settingsConfirm .env is in the project root.
Azure DevOps returns 401 or 403Check credential expiry and permissions.
Wrong pipeline dataRecheck project and numeric pipeline ID.
Visual Studio Code does not show the serverValidate .vscode/mcp.json and reload Visual Studio Code.
Queueing is disabledKeep the safe default unless an intentional test is approved.
Unsupported parametersUse only reviewed entries in MCP_ALLOWED_PARAMETERS.

Production considerations

  • Prefer Microsoft Entra-backed authentication or the hosted Azure DevOps MCP server where supported.
  • Use least privilege and separate read from queue permissions.
  • Never return tokens, headers or unredacted logs.
  • Keep Azure DevOps approvals and checks as the release authority.
  • Add bounded outputs, timeouts, rate limits and safe correlation logging before remote hosting.

Conclusion

Part 1 established the deterministic image lifecycle. This second part adds a practical operations surface: Visual Studio Code starts the local MCP server from workspace configuration, discovers the curated tools and uses Azure DevOps as the source of build and release truth.

References

Updated Nov 04, 2026
Version 1.0