---
title: "Microsoft Graph API MCP Server: Automate User Provisioning"
description: "Build a local Microsoft Graph API MCP server in Python to create disabled test users, assign an approved license, and verify each provisioning step."
canonical: "https://adamtheautomator.com/microsoft-graph-api-mcp-server-provisioning/"
---

# Microsoft Graph API MCP Server: Automate User Provisioning

> Build a local Microsoft Graph API MCP server in Python to create disabled test users, assign an approved license, and verify each provisioning step.

Source: https://adamtheautomator.com/microsoft-graph-api-mcp-server-provisioning/

---

ATA Learning

Tap to hide

[

ATA Learning

](/)

*   [Home](/)
*   [Tutorials](/tutorials/)
*   [Instructors](/author/)
*   [Advertising](/advertising/)
*   [Recommended Resources](/resources/)
*   [About Adam](/about-adam/)

Search for:  

*   [](https://twitter.com/adbertram)
*   [](https://github.com/Adam-the-Automator)
*   [](https://www.linkedin.com/company/adam-the-automator-llc)
*   [](/feed/)

![Microsoft Graph API MCP Server: Automate User Provisioning](https://adamtheautomator.com/wp-content/uploads/publisher/4065d9c85b2b81c2b28df93eee431250/47ab8e33fb2d780dfd7da807ba1617d86ba92a550b57fa1b3c98e4981b0da276.webp)

# Microsoft Graph API MCP Server: Automate User Provisioning

[![](https://secure.gravatar.com/avatar/d0b9d42e21e5622713f8b693aa5c0f9244d5f7dd200ed29b8398f52dee5de337?s=192&d=mm&r=g)Adam Bertram](https://adamtheautomator.com/author/adam-bertram/)6 November 202621 min. read

Categories: [AI Agents](/category/ai-agents/)

Tags:[Microsoft Graph](/tag/microsoft-graph/)[Python](/tag/python/)[Microsoft Entra](/tag/microsoft-entra/)[Artificial Intelligence](/tag/artificial-intelligence/)

Table of Contents

*   [Prerequisites](#prerequisites)
*   [Register an Application With Specific Graph Permissions](#register-an-application-with-specific-graph-permissions)
*   [Resolve Permission IDs From Your Tenant](#resolve-permission-ids-from-your-tenant)
*   [Create and Consent to the Registration](#create-and-consent-to-the-registration)
*   [Give the Server a Certificate Credential](#give-the-server-a-certificate-credential)
*   [Generate and Register the Certificate](#generate-and-register-the-certificate)
*   [Understand the Two Access Boundaries](#understand-the-two-access-boundaries)
*   [Build the Microsoft Graph API MCP Server in Python](#build-the-microsoft-graph-api-mcp-server-in-python)
*   [Install the Dependencies](#install-the-dependencies)
*   [Initialize Shared State Once](#initialize-shared-state-once)
*   [Expose License Inventory and Validated User Creation](#expose-license-inventory-and-validated-user-creation)
*   [Keep Account Choices Inside the Server Policy](#keep-account-choices-inside-the-server-policy)
*   [Treat an Existing UPN as a Reconciliation Decision](#treat-an-existing-upn-as-a-reconciliation-decision)
*   [Assign the Approved License and Verify the User](#assign-the-approved-license-and-verify-the-user)
*   [Check Inventory Before Consuming a Seat](#check-inventory-before-consuming-a-seat)
*   [Separate an Accepted Assignment From Workload Readiness](#separate-an-accepted-assignment-from-workload-readiness)
*   [Connect an MCP Client and Provision a Test Account](#connect-an-mcp-client-and-provision-a-test-account)
*   [Run a Direct MCP Inventory Check](#run-a-direct-mcp-inventory-check)
*   [Connect Your Agent Host](#connect-your-agent-host)
*   [Recover From Errors Without Repeating Successful Writes](#recover-from-errors-without-repeating-successful-writes)
*   [Use the Failed Stage to Choose the Next Action](#use-the-failed-stage-to-choose-the-next-action)
*   [Prove the Refusal Paths Too](#prove-the-refusal-paths-too)
*   [Questions About Existing Accounts and Approvals](#questions-about-existing-accounts-and-approvals)
*   [Can I License an Existing Employee?](#can-i-license-an-existing-employee)
*   [Does a Host Approval Reduce the Application’s Graph Permissions?](#does-a-host-approval-reduce-the-applications-graph-permissions)
*   [Remove the Lab Resources Before Sharing the Server](#remove-the-lab-resources-before-sharing-the-server)
*   [Delete Only the Recorded Lab Resources](#delete-only-the-recorded-lab-resources)
*   [Set the Boundary for a Shared Service](#set-the-boundary-for-a-shared-service)

Follow an onboarding request past the words “create a user,” and pay attention to who gets to choose the tenant, the account name, and the license. Give an AI agent that whole decision and you also give it plenty of ways to provision the wrong account. Use the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/docs/learn/architecture) to expose specific [Python functions](https://docs.python.org/3/tutorial/controlflow.html#defining-functions) to your agent, with those choices constrained by your server.

Build a local [Microsoft Graph API](https://learn.microsoft.com/en-us/graph/overview) MCP server that creates disabled test users and assigns an approved license. Register its application and certificate, then build the server and connect an MCP client. Test provisioning and recovery in your tenant before removing the lab resources. Use this separate custom Python implementation for user creation and license assignment; Microsoft’s hosted [MCP Server for Enterprise](https://learn.microsoft.com/en-us/graph/mcp-server/overview) exposes read-only Graph tools.

## Prerequisites

Bring these pieces to your development environment:

*   A [Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/fundamentals/what-is-entra) test tenant and a Global Administrator account for the explicit administrator-consent command.
    
*   A verified, nonfederated tenant domain and an available user license to satisfy Graph’s account and licensing requirements.
    
*   Python 3.14.7 and [Bash](https://www.gnu.org/software/bash/manual/bash.html) for the local examples.
    
*   [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) and [OpenSSL 3](https://docs.openssl.org/3.0/man1/openssl-req/) to register the application and generate its certificate.
    

## Register an Application With Specific Graph Permissions

Give your application only the permissions needed for the Microsoft Graph operations you will expose:

| Operation | Application Permission |
| --- | --- |
| [Create a directory user](https://learn.microsoft.com/en-us/graph/api/user-post-users?view=graph-rest-1.0) | `User.Create` |
| [Read license inventory](https://learn.microsoft.com/en-us/graph/api/subscribedsku-list?view=graph-rest-1.0) | `LicenseAssignment.Read.All` |
| [Assign a user license](https://learn.microsoft.com/en-us/graph/api/user-assignlicense?view=graph-rest-1.0) | `LicenseAssignment.ReadWrite.All` |
| [Read the account back](https://learn.microsoft.com/en-us/graph/api/user-get?view=graph-rest-1.0) | `User.Read.All` |

Follow the remaining build sections in order:

1.  Give the server a certificate credential.
    
2.  Build the Microsoft Graph API MCP server in Python.
    
3.  Expose inventory and user creation.
    
4.  Assign the approved license and verify the user.
    
5.  Connect your MCP client.
    
6.  Recover from failed calls.
    
7.  Check the provisioning boundaries.
    
8.  Remove the lab resources.
    

These are application permissions: Graph authorizes the server’s identity without a signed-in user. Delegated permissions use a user’s identity and introduce a different authorization model. Microsoft Learn’s [Get access without a user](https://learn.microsoft.com/en-us/graph/auth-v2-service) says: “These permissions always require administrator consent.” Adding a permission to a registration does not complete that consent step.

### Resolve Permission IDs From Your Tenant

Run the setup commands in one Bash session from a new project directory. Replace every angle-bracket value with your own configuration, and stop if a command fails. [Sign in to the intended tenant](https://learn.microsoft.com/en-us/cli/azure/reference-index?view=azure-cli-latest#az-login) first:

```bash
export AZURE_TENANT_ID="<tenant-id>"
az login --tenant "$AZURE_TENANT_ID" --allow-no-subscriptions
```

`--allow-no-subscriptions` permits directory administration when your test tenant has no Azure subscription. Check the tenant in the returned account information before continuing. The CLI login authorizes your setup work; the Python server will authenticate separately with its certificate.

Microsoft Graph exposes application permission IDs through its service principal’s `appRoles`. [Retrieve that catalog](https://learn.microsoft.com/en-us/cli/azure/ad/sp?view=azure-cli-latest#az-ad-sp-show) rather than copying an old permissions GUID table:

```bash
az ad sp show \
  --id 00000003-0000-0000-c000-000000000000 \
  --query appRoles --output json > graph_roles.json
```

The fixed ID identifies Microsoft Graph in the public cloud. Save the following as `build_permissions.py` to turn the catalog into an application manifest fragment:

```python
from __future__ import annotations

import json
from pathlib import Path


def main() -> int:
    roles = json.loads(Path("graph_roles.json").read_text())
    if not isinstance(roles, list) or not roles:
        raise ValueError("Graph application-role catalog is missing or empty")
    wanted = [
        "User.Create", "User.Read.All",
        "LicenseAssignment.Read.All", "LicenseAssignment.ReadWrite.All",
    ]
    access = []
    for name in wanted:
        matches = [role for role in roles if role.get("value") == name
                   and role.get("isEnabled")
                   and "Application" in role.get("allowedMemberTypes", [])]
        if len(matches) != 1:
            raise ValueError(f"Expected one enabled application permission: {name}")
        access.append({"id": matches[0]["id"], "type": "Role"})
    manifest = [{"resourceAppId": "00000003-0000-0000-c000-000000000000",
                 "resourceAccess": access}]
    Path("graph_permissions.json").write_text(json.dumps(manifest, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```

Run `python3 build_permissions.py`. Missing permissions stop manifest generation with the permission name to investigate. Substituting `Directory.ReadWrite.All` would make the error disappear by handing the application far more authority. That is an expensive way to avoid reading a permission table.

### Create and Consent to the Registration

You are about to create a directory application and authorize real directory writes.

* * *

_**Warning: Use a test tenant. This application can create accounts and assign licenses, and an assigned license consumes an available seat. Skip the setup and provisioning commands against a production tenant.**_

* * *

[Create the single-tenant registration](https://learn.microsoft.com/en-us/cli/azure/ad/app?view=azure-cli-latest#az-ad-app-create) with the generated manifest:

```bash
AZURE_CLIENT_ID=$(az ad app create \
  --display-name graph-mcp-lab \
  --sign-in-audience AzureADMyOrg \
  --required-resource-accesses @graph_permissions.json \
  --query appId --output tsv)
export AZURE_CLIENT_ID
```

`AzureADMyOrg` limits the registration to your tenant. The `Role` entries request application permissions. Keep the returned client ID; it identifies the application used throughout this tutorial.

Create its service principal, the tenant-local identity that receives the grants:

```bash
az ad sp create --id "$AZURE_CLIENT_ID" --output none
```

Then run the [administrator-consent command](https://learn.microsoft.com/en-us/cli/azure/ad/app/permission?view=azure-cli-latest#az-ad-app-permission-admin-consent) under your Global Administrator login:

```bash
az ad app permission admin-consent --id "$AZURE_CLIENT_ID"
```

The Python server will request the consented Graph scopes with `.default`. The underlying application grants remain in force through the narrow MCP interface. Credential theft lets an attacker bypass your Python validation. Protect that credential before connecting an agent.

## Give the Server a Certificate Credential

Keep the private key outside your project and accessible only to your operating-system account. [Azure Identity’s asynchronous `CertificateCredential`](https://learn.microsoft.com/en-us/python/api/azure-identity/azure.identity.aio.certificatecredential?view=azure-python) accepts a PEM file containing the certificate and its RSA private key. The server will use that credential to obtain its Graph access tokens.

### Generate and Register the Certificate

Prepare a private directory in the same Bash session:

```bash
umask 077
CERT_DIR="$HOME/.config/graph-mcp-lab"
mkdir -p "$CERT_DIR"
chmod 700 "$CERT_DIR"
```

`umask 077` restricts newly created files to your account. Keep that protection for the combined credential file too. For this disposable lab, [generate a self-signed certificate](https://docs.openssl.org/3.0/man1/openssl-req/) valid for 30 days:

```bash
openssl req -x509 -newkey rsa:2048 -sha256 -noenc \
  -keyout "$CERT_DIR/key.pem" -out "$CERT_DIR/cert.pem" \
  -days 30 -subj "/CN=graph-mcp-lab"
```

`-noenc` leaves the private key unencrypted, so file access is its protection in this example. Use your approved secret storage and credential lifecycle for a deployed service. Never upload the private key to the app registration or paste it into an MCP configuration.

[Append the public certificate](https://learn.microsoft.com/en-us/cli/azure/ad/app/credential?view=azure-cli-latest#az-ad-app-credential-reset) to the application:

```bash
az ad app credential reset --id "$AZURE_CLIENT_ID" \
  --cert "@$CERT_DIR/cert.pem" --append --output none
```

`--append` preserves existing credentials. The command receives only the public certificate. Combine the local key and certificate for Azure Identity:

```bash
cat "$CERT_DIR/key.pem" "$CERT_DIR/cert.pem" > "$CERT_DIR/credential.pem"
```

Export the credential’s path and your verified account domain, and leave writes disabled for the initial authentication and license-inventory check:

```bash
export AZURE_CLIENT_CERTIFICATE_PATH="$CERT_DIR/credential.pem"
export GRAPH_USER_DOMAIN="<verified-tenant-domain>"
export ENABLE_GRAPH_WRITES=0
```

The IDs and file path are configuration. The private key remains in the protected file.

### Understand the Two Access Boundaries

Your local MCP client launches a process and communicates through standard input and output, called _stdio transport_. The operating-system account and trusted launcher control access to that process. The certificate independently authenticates its calls to Graph. The [SDK authorization guidance](https://github.com/modelcontextprotocol/python-sdk/blob/v2.3.0/docs/run/authorization.md) distinguishes this local arrangement from HTTP bearer-token verification.

The diagram below separates those boundaries. The model supplies account fields, while the server holds the credential and chooses the Graph destination. The operator also selects the [stock-keeping unit (SKU)](https://learn.microsoft.com/en-us/graph/api/resources/subscribedsku?view=graph-rest-1.0), the identifier for the license product the server may assign.

![Boundaries between the agent host, the MCP server, and Microsoft Graph](https://adamtheautomator.com/wp-content/uploads/publisher/efde4f611a74eda73de58f5f110b36ea1dc7823733f19b083a489bd28fbfa659.png)

Moving this server to a [remote HTTP listener](https://adamtheautomator.com/practical-guide-hosting-managing-remote-mcp/) requires separate caller authentication and authorization. Keep this example local while you establish the provisioning behavior; opening a port would add an unfinished security boundary.

## Build the Microsoft Graph API MCP Server in Python

You will use the official [MCP Python SDK 2.3.0](https://pypi.org/project/mcp/2.3.0/). Its high-level server is `MCPServer`; older `mcp.server.fastmcp.FastMCP` examples target the SDK’s earlier API. Keep the package and import paths together when following this guide.

The local checks for this example used Python 3.14.7, MCP 2.3.0, Azure Identity 1.27.0, [HTTPX 0.28.1](https://www.python-httpx.org/async/), and [Pydantic 2.14.3](https://docs.pydantic.dev/latest/concepts/models/). They exercised the MCP protocol with simulated Graph responses. Tenant registration, administrator consent, and actual cloud provisioning are unverified by those checks and require a run in your test tenant.

### Install the Dependencies

Create a [virtual environment](https://docs.python.org/3/library/venv.html) in your project directory:

```bash
python3 -m venv .venv
```

Install the versions used by the local checks:

```bash
.venv/bin/python -m pip install \
  "mcp==2.3.0" "azure-identity==1.27.0" \
  "httpx==0.28.1" "pydantic==2.14.3" "aiohttp==3.14.5"
```

Using the environment’s interpreter explicitly avoids accidentally installing a package into a different Python. [HTTPX’s async client](https://www.python-httpx.org/async/) makes the outgoing REST calls with the Graph request bodies explicit for comparison with the endpoint documentation; Azure Identity handles token acquisition through aiohttp.

### Initialize Shared State Once

Create `graph_server.py` with the following foundation. You will append the tools in the next two sections, then add the entry point at the bottom.

```python
from __future__ import annotations

import json
import logging
import os
import re
import secrets
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import Any, Literal
from urllib.parse import quote
from uuid import UUID, uuid4

import httpx
from azure.core.exceptions import ClientAuthenticationError
from azure.identity.aio import CertificateCredential
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
from mcp.server.mcpserver.exceptions import ToolError
from pydantic import BaseModel, ConfigDict, Field

LOG = logging.getLogger("graph-mcp")
SELECT_USER = (
    "id,displayName,userPrincipalName,usageLocation,"
    "assignedLicenses,licenseAssignmentStates"
)


class GraphFault(ToolError):
    def __init__(self, detail: dict[str, Any]) -> None:
        self.detail = detail
        super().__init__(json.dumps(detail))


@dataclass
class State:
    credential: CertificateCredential
    http: httpx.AsyncClient
    domain: str
    writes: bool
    sku_id: str | None
    created: dict[str, str] = field(default_factory=dict)


@asynccontextmanager
async def lifespan(server: MCPServer) -> AsyncIterator[State]:
    domain = os.environ["GRAPH_USER_DOMAIN"].lower()
    if not re.fullmatch(r"[a-z0-9-]+(?:\.[a-z0-9-]+)+", domain):
        raise ValueError("GRAPH_USER_DOMAIN must be a verified tenant domain")
    write_flag = os.environ.get("ENABLE_GRAPH_WRITES", "0")
    if write_flag not in {"0", "1"}:
        raise ValueError("ENABLE_GRAPH_WRITES must be 0 or 1")
    sku_text = os.environ.get("GRAPH_APPROVED_SKU_ID")
    sku_id = str(UUID(sku_text)) if sku_text else None
    if write_flag == "1" and sku_id is None:
        raise ValueError("Writes require GRAPH_APPROVED_SKU_ID")
    credential = CertificateCredential(
        tenant_id=str(UUID(os.environ["AZURE_TENANT_ID"])),
        client_id=str(UUID(os.environ["AZURE_CLIENT_ID"])),
        certificate_path=os.environ["AZURE_CLIENT_CERTIFICATE_PATH"],
    )
    try:
        async with httpx.AsyncClient(
            base_url="https://graph.microsoft.com/v1.0/",
            timeout=30.0,
            follow_redirects=False,
        ) as http:
            yield State(credential, http, domain, write_flag == "1", sku_id)
    finally:
        await credential.close()


server = MCPServer("graph-user-provisioning", lifespan=lifespan)


async def graph_request(
    state: State,
    method: str,
    path: str,
    *,
    body: dict[str, Any] | None = None,
    params: dict[str, str] | None = None,
    missing_ok: bool = False,
) -> dict[str, Any] | None:
    request_id = str(uuid4())
    try:
        token = await state.credential.get_token(
            "https://graph.microsoft.com/.default"
        )
    except ClientAuthenticationError:
        raise ToolError("Graph authentication failed; operator action required") from None
    try:
        response = await state.http.request(
            method,
            path,
            params=params,
            json=body,
            headers={
                "Authorization": f"Bearer {token.token}",
                "client-request-id": request_id,
            },
        )
    except httpx.RequestError:
        raise GraphFault({
            "stage": "graph_transport",
            "outcome": "unknown" if method == "POST" else "read_failed",
            "request_id": request_id,
            "action": "Reconcile directory state before repeating a write",
        }) from None
    LOG.info("method=%s status=%s request_id=%s", method, response.status_code, request_id)
    if missing_ok and response.status_code == 404:
        result = None
    elif response.is_error:
        raise GraphFault({
            "status": response.status_code,
            "request_id": request_id,
            "retry_after": response.headers.get("Retry-After"),
        })
    else:
        result = response.json()
        if not isinstance(result, dict):
            raise ToolError("Graph returned an unexpected response shape")
    return result


async def inventory(state: State) -> list[dict[str, Any]]:
    data = await graph_request(state, "GET", "subscribedSkus")
    if data is None or not isinstance(data.get("value"), list):
        raise ToolError("Graph returned no usable license inventory")
    if data.get("@odata.nextLink"):
        raise ToolError("Inventory is paginated; operator must add paging before writes")
    return data["value"]


def require_writes(state: State) -> None:
    if not state.writes:
        raise ToolError("Writes disabled by server configuration")


def require_created(state: State, user_id: UUID) -> str:
    identifier = str(user_id)
    if identifier not in state.created:
        raise ToolError("User was not created by this server process; operator review required")
    return identifier
```

The SDK’s [lifespan handler](https://github.com/modelcontextprotocol/python-sdk/blob/v2.3.0/docs/handlers/lifespan.md) initializes the credential and HTTP client once and closes them during shutdown. `Context[State]` later gives each tool access to those objects without putting credentials in its input schema.

This foundation makes several application choices explicit:

*   `ENABLE_GRAPH_WRITES` defaults to `0`; malformed values stop startup.
    
*   Enabling writes requires an operator-selected SKU ID.
    
*   The Graph hostname and API version are fixed in code for the public cloud.
    
*   The HTTP client has a 30-second timeout and follows no redirects.
    
*   Write transport failures return an unknown outcome that requires reconciliation.
    

The request ID goes in the outgoing header and local diagnostic record. Tokens and request bodies stay out of that record. Avoid debug-level HTTP logging around provisioning: a create request contains the bootstrap password even though your tool result never returns it.

The helper performs one HTTP request per invocation. It deliberately leaves retries to the recovery policy you will examine below. Async calls let the process await network I/O; they don’t make a completed write reversible or safe to repeat.

## Expose License Inventory and Validated User Creation

Append the following model and functions to `graph_server.py`, after the connection layer, to expose a read operation and a constrained account-creation operation:

```python
class NewUser(BaseModel):
    model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
    display_name: str = Field(min_length=1, max_length=256)
    alias: str = Field(pattern=r"^[a-z][a-z0-9]{2,23}$")
    usage_location: Literal["US", "GB", "CA"]


@server.tool()
async def list_license_products(ctx: Context[State]) -> list[dict[str, Any]]:
    """Read tenant license products. This tool makes no changes."""
    products = await inventory(ctx.request_context.lifespan_context)
    fields = ("skuId", "skuPartNumber", "appliesTo", "capabilityStatus", "consumedUnits", "prepaidUnits")
    if any(any(key not in product for key in fields) for product in products):
        raise ToolError("License inventory lacks required fields")
    return [{key: product[key] for key in fields} for product in products]


@server.tool()
async def create_user(user: NewUser, ctx: Context[State]) -> dict[str, Any]:
    """Create a disabled cloud-only lab user in the configured domain."""
    state = ctx.request_context.lifespan_context
    require_writes(state)
    alias = f"mcp-lab-{user.alias}"
    upn = f"{alias}@{state.domain}"
    existing = await graph_request(
        state, "GET", f"users/{quote(upn, safe='')}",
        params={"$select": SELECT_USER}, missing_ok=True,
    )
    if existing is not None:
        if (
            existing["id"] not in state.created
            or existing["displayName"] != user.display_name
            or existing["usageLocation"] != user.usage_location
        ):
            raise ToolError("UPN already exists; operator must reconcile this account")
        result = {"stage": "already_created", "user_id": existing["id"], "upn": upn}
    else:
        created = await graph_request(state, "POST", "users", body={
            "accountEnabled": False,
            "displayName": user.display_name,
            "mailNickname": alias,
            "userPrincipalName": upn,
            "usageLocation": user.usage_location,
            "passwordProfile": {
                "forceChangePasswordNextSignIn": True,
                "password": "aA1!" + secrets.token_urlsafe(24),
            },
        })
        if created is None or not created.get("id"):
            raise ToolError(f"Creation response lacks an ID; reconcile {upn} before retrying")
        identifier = str(UUID(created["id"]))
        state.created[identifier] = upn
        LOG.info("stage=user_created user_id=%s", identifier)
        result = {"stage": "user_created", "user_id": identifier, "upn": upn}
    return result
```

### Keep Account Choices Inside the Server Policy

The alias accepts a lowercase letter followed by 2 to 23 lowercase letters or digits. The server adds `mcp-lab-` and its configured domain, producing a user principal name (UPN), the account’s sign-in name. Your model can’t choose another tenant, inject a different domain, or send an arbitrary Graph payload.

The country allowlist contains the United States, United Kingdom, and Canada for this example. Extend this typed allowlist deliberately for your lab’s actual countries; it defines the caller’s schema and rejects `ZZ` before Graph, whereas a two-character string check would accept that invalid country.

[Graph’s user resource](https://learn.microsoft.com/en-us/graph/api/resources/user?view=graph-rest-1.0) defines `usageLocation` as the country used for licensing. Supply it during creation so you avoid a separate update solely to make licensing work. `extra="forbid"` also rejects invented input fields: `approved=true` is one example.

### Treat an Existing UPN as a Reconciliation Decision

Before [posting the new user](https://learn.microsoft.com/en-us/graph/api/user-post-users?view=graph-rest-1.0), the tool checks the derived UPN. For an identical call in the same process, the tool returns `already_created` and the original user ID. Existing accounts without that provenance require operator review, even when the display name looks right.

The `created` mapping is intentionally limited to the current process. It prevents subsequent tools from adopting arbitrary directory accounts, but it isn’t durable request tracking. Restarting the process clears the mapping and closes that scope. For production, replace it with persisted operation records tied to approved inputs before you promise restart-safe resumption.

The new account remains disabled. Its random bootstrap password satisfies the creation request and is discarded rather than sent back to the agent. Activating a real employee account requires an approved password-delivery or reset workflow and the appropriate account-enable permissions. Do not “finish” onboarding by printing a password into the chat transcript.

The read-before-create check improves repeat handling but cannot reserve the UPN. Concurrent requests can race; Graph still decides whether creation succeeds. After an ambiguous timeout, look up the exact account in the directory before any new write. Licensing remains restricted to accounts recorded in the process-local mapping.

## Assign the Approved License and Verify the User

Append the remaining tools and entry point to `graph_server.py`. The licensing tool accepts a created user’s ID; the approved SKU comes from trusted configuration, so the agent cannot upgrade its request by choosing a different product.

```python
@server.tool()
async def verify_user(user_id: UUID, ctx: Context[State]) -> dict[str, Any]:
    """Read selected properties for a user created in this process."""
    state = ctx.request_context.lifespan_context
    identifier = require_created(state, user_id)
    result = await graph_request(
        state, "GET", f"users/{identifier}", params={"$select": SELECT_USER}
    )
    if result is None:
        raise ToolError("Graph returned no user")
    return result


@server.tool()
async def assign_license(user_id: UUID, ctx: Context[State]) -> dict[str, Any]:
    """Assign only the operator-approved SKU to a user created in this process."""
    state = ctx.request_context.lifespan_context
    require_writes(state)
    identifier = require_created(state, user_id)
    user = await verify_user(user_id, ctx)
    if any(item["skuId"] == state.sku_id for item in user["assignedLicenses"]):
        result = {"stage": "already_assigned", "user_id": identifier, "sku_id": state.sku_id}
    else:
        products = await inventory(state)
        matches = [item for item in products if item.get("skuId") == state.sku_id]
        if len(matches) != 1:
            raise ToolError("Approved SKU is absent or ambiguous in this tenant")
        product = matches[0]
        if product["appliesTo"] != "User" or product["capabilityStatus"] != "Enabled":
            raise ToolError("Approved SKU is not an enabled user license")
        units = product.get("prepaidUnits")
        enabled = units.get("enabled") if isinstance(units, dict) else None
        consumed = product.get("consumedUnits")
        if type(enabled) is not int or type(consumed) is not int:
            raise ToolError("Approved SKU lacks usable enabled/consumed seat counts")
        available = enabled - consumed
        if available <= 0:
            raise ToolError("Approved SKU has no available seats; user remains created")
        try:
            await graph_request(state, "POST", f"users/{identifier}/assignLicense", body={
                "addLicenses": [{"skuId": state.sku_id, "disabledPlans": []}],
                "removeLicenses": [],
            })
        except GraphFault as exc:
            raise ToolError(json.dumps({
                "stage": "license_assignment_failed",
                "user_id": identifier,
                "graph": exc.detail,
            })) from None
        result = {"stage": "assignment_accepted", "user_id": identifier, "sku_id": state.sku_id}
    return result


def main() -> int:
    logging.basicConfig(level=logging.WARNING)
    LOG.setLevel(logging.INFO)
    server.run()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```

### Check Inventory Before Consuming a Seat

The [subscribed SKU resource](https://learn.microsoft.com/en-us/graph/api/resources/subscribedsku?view=graph-rest-1.0) distinguishes the object’s `id` from its assignable `skuId`. Use `skuId` from your tenant’s inventory; copying a product name from another tenant doesn’t establish that you own the same subscription.

The code requires an enabled product that applies to users, then compares enabled units with consumed units. That observation helps reject an exhausted subscription before writing. Another provisioning process can consume the last seat immediately afterward. So Graph’s assignment response remains authoritative.

If Graph returns `@odata.nextLink`, implement and test supported paging before enabling writes. The lab helper makes one inventory request and refuses a response containing a continuation link, so partial license data cannot pass as a complete inventory.

The [assignLicense request](https://learn.microsoft.com/en-us/graph/api/user-assignlicense?view=graph-rest-1.0) includes `removeLicenses` even when empty. `disabledPlans: []` requests all service plans in the selected product. If your provisioning policy excludes particular plans, build and validate that policy on the server before using the tool for employee onboarding.

### Separate an Accepted Assignment From Workload Readiness

If the user already has the approved license, the tool returns `already_assigned` without another POST. After a new assignment, the result says `assignment_accepted`. Use `verify_user` to inspect `assignedLicenses` and `licenseAssignmentStates`; a matching SKU alone doesn’t establish that every workload is ready.

The verification request explicitly selects its properties because [Get a user](https://learn.microsoft.com/en-us/graph/api/user-get?view=graph-rest-1.0) returns only a subset by default. Check that the requested SKU appears and inspect assignment-state errors before declaring the licensing step healthy. Account activation remains a separate administrative workflow; this server sends `accountEnabled: false` during creation and exposes no activation tool.

Creation and assignment are separate changes. If the second request fails, the first account persists, and the tool’s error includes its ID. Keep that ID for recovery; creating another account would turn one licensing problem into an account-cleanup problem too.

## Connect an MCP Client and Provision a Test Account

Check the file before letting your agent launch it:

```bash
.venv/bin/python -m py_compile graph_server.py
```

Follow the syntax check with a read-only MCP request to test live tenant consent and license capacity.

### Run a Direct MCP Inventory Check

Save this as `check_client.py` beside the server. The [SDK’s stdio client](https://github.com/modelcontextprotocol/python-sdk/blob/v2.3.0/docs/client/transports.md) launches the subprocess with a restricted environment, so pass the required settings explicitly:

```python
from __future__ import annotations

import asyncio
import json
import os
import sys
from pathlib import Path

from mcp import Client, StdioServerParameters


async def main() -> int:
    required = (
        "AZURE_TENANT_ID", "AZURE_CLIENT_ID",
        "AZURE_CLIENT_CERTIFICATE_PATH", "GRAPH_USER_DOMAIN",
    )
    env = {name: os.environ[name] for name in required}
    env["ENABLE_GRAPH_WRITES"] = "0"
    process = StdioServerParameters(
        command=sys.executable,
        args=[str(Path(__file__).with_name("graph_server.py"))],
        env=env,
    )
    async with Client(process, read_timeout_seconds=60) as client:
        tools = await client.list_tools()
        print([tool.name for tool in tools.tools])
        result = await client.call_tool("list_license_products")
        if result.is_error:
            raise RuntimeError(result.content)
        print(json.dumps(result.structured_content, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(asyncio.run(main()))
```

Run `.venv/bin/python check_client.py`. Expect the four tool names and your tenant’s license inventory, with `is_error` checked before the inventory is printed. But MCP can return a valid protocol response even when tool execution fails.

Select an enabled user product with capacity from that result and record its `skuId`. Keep `ENABLE_GRAPH_WRITES=0` during this inspection. If authentication fails here, investigate the application ID, certificate registration, tenant ID, and consent before introducing account creation as another variable.

### Connect Your Agent Host

For the [Visual Studio Code Copilot configuration](https://code.visualstudio.com/docs/agents/reference/mcp-configuration), add the server to the user’s portable MCP configuration, `~/.copilot/mcp-config.json`. If you set `COPILOT_HOME`, use `mcp-config.json` in that directory instead. Merge this entry with any existing servers. Replace the placeholders with your verified values and absolute local paths:

```json
{
  "mcpServers": {
    "graph-provisioning": {
      "command": "<absolute-project-path>/.venv/bin/python",
      "args": ["<absolute-project-path>/graph_server.py"],
      "env": {
        "AZURE_TENANT_ID": "<tenant-id>",
        "AZURE_CLIENT_ID": "<application-client-id>",
        "AZURE_CLIENT_CERTIFICATE_PATH": "<absolute-path-to-credential.pem>",
        "GRAPH_USER_DOMAIN": "<verified-tenant-domain>",
        "GRAPH_APPROVED_SKU_ID": "<skuId-from-your-inventory>",
        "ENABLE_GRAPH_WRITES": "0"
      }
    }
  }
}
```

Start the configured server through your host’s MCP controls and confirm that all four tools appear. Ask the agent to call only `list_license_products`. Request account creation while writes remain disabled and check for the server’s writes-disabled error. Keep the host’s tool approvals enabled while you inspect the arguments and results.

When the tenant, domain, and SKU are correct, change the configuration’s write flag to `1` and restart this MCP server once. Create and license the user in that same running process, because a restart between those operations clears the process-local provenance mapping.

* * *

_**Reality Check: Enabling writes authorizes this local process to create accounts. Host approval prompts let you inspect a call. Production approval must bind to the exact account and license in a trusted system outside the model’s arguments.**_

* * *

Use a specific instruction that makes the intended tool sequence inspectable:

```text
Create one disabled lab user with display_name "MCP Lab User",
alias "test01", and usage_location "US" using create_user.
Record its returned user_id. Assign the server-approved license to that ID,
then call verify_user. Stop on an error and report the failed stage and ID.
```

The `create_user` arguments are nested beneath `user`, as the typed tool schema specifies. For a deterministic tool call, the payload is:

```json
{
  "user": {
    "display_name": "MCP Lab User",
    "alias": "test01",
    "usage_location": "US"
  }
}
```

On successful creation, the model-visible result contains `stage`, `user_id`, and `upn`. Those field names and stage values were exercised with simulated Graph responses; actual IDs and tenant inventory come from your run. Record them as your teardown evidence.

## Recover From Errors Without Repeating Successful Writes

Your first failure might arrive after Graph has already created the account. Preserve the returned ID and consult directory state before changing inputs or replaying the request. The SDK’s [tool-error handling](https://github.com/modelcontextprotocol/python-sdk/blob/v2.3.0/docs/servers/handling-errors.md) lets the host present execution failures through `isError`; a tool rejection doesn’t require pretending the MCP connection itself broke.

### Use the Failed Stage to Choose the Next Action

| Observation | Operator Action |
| --- | --- |
| Writes disabled | Inspect trusted configuration; do not add an approval argument. |
| Existing UPN requires reconciliation | Read that exact account and establish ownership before further changes. |
| Graph 401 or authentication failure | Check certificate validity, tenant, client ID, and Graph token configuration. |
| Graph 403 | Check the failed operation’s application permission and administrator consent. |
| Graph 429 | Respect the returned `Retry-After` before a controlled retry. |
| Unknown POST outcome | Read directory state before deciding whether another write is needed. |
| User created, licensing failed | Preserve the user; resolve licensing and resume against its ID. |

For [Graph throttling](https://learn.microsoft.com/en-us/graph/throttling), respect `Retry-After`. When that header is absent, Microsoft recommends exponential backoff. For production retries, set an attempt limit and a deadline; reconcile ambiguous write outcomes before another attempt. The lab helper returns the header and stops after one request, with no automatic retries.

Do not give the model a generic “try harder” instruction for a 403. Repetition can’t grant admin consent. Likewise, creating a different username to get past a timeout leaves you with a second account and less certainty about the first.

The recovery chart below shows where the durable directory changes survive a failed tool call.

![Provisioning recovery states and the branches that require operator action](https://adamtheautomator.com/wp-content/uploads/publisher/a43a40fd7e81af52ab3fe82578e8f1193ef8f6c39e42e1ee871b5b6c21053c95.png)

### Prove the Refusal Paths Too

Before adapting the tools, exercise these cases in your lab and record the account IDs involved:

1.  Repeat the identical create call in the same process. Expect `already_created` and the same ID.
    
2.  Submit an invalid country, an alias containing `@`, and an extra `approved` field. Expect input rejection before a write.
    
3.  Request assignment for an ID this process did not create. Expect refusal.
    
4.  If licensing fails during your tenant run, retain the created user ID. After resolving the reported cause, retry assignment against that ID and verify that no additional account was created.
    
5.  Restart the server and attempt to adopt the old ID. Expect operator review rather than silent resumption.
    

The local checks covered these behaviors with simulated HTTP responses, including a 429 and a lost creation response. Keep real tenant validation separate in your records. Tenant password policy, subscription availability, and administrative controls can reject operations that a local simulation accepts.

## Questions About Existing Accounts and Approvals

You have a working interface for newly created lab accounts. Before broadening it, settle which account relationships and approvals your server can prove.

### Can I License an Existing Employee?

The [Graph licensing endpoint](https://learn.microsoft.com/en-us/graph/api/user-assignlicense?view=graph-rest-1.0) accepts an existing user ID. This implementation deliberately restricts that operation to IDs it created during its current run. Supporting existing employees requires an explicit authorization check for the target account and approved product. Remove the provenance check only after replacing it with that policy; accepting any well-formed UUID would expose a tenant-wide licensing operation through the tool.

### Does a Host Approval Reduce the Application’s Graph Permissions?

Graph authorizes your application using its [consented application permissions](https://learn.microsoft.com/en-us/graph/auth-v2-service). Host approval controls whether that host invokes a tool. For a shared deployment, record who authorized the exact account and SKU, verify that authorization on the server, and retain the operation’s result. Keep the application credential protected because those host controls can’t constrain someone calling Graph directly with the credential.

## Remove the Lab Resources Before Sharing the Server

Use your administrator’s CLI session for teardown because deletion is deliberately absent from both the server’s tools and its Graph permissions.

### Delete Only the Recorded Lab Resources

Stop the MCP process first, then inspect the exact ID returned by your test:

```bash
export TEST_USER_ID="<user-id-returned-by-create_user>"
az ad user show --id "$TEST_USER_ID" \
  --query '{id:id,upn:userPrincipalName}' --output json
```

Confirm the `mcp-lab-` account name and intended domain. The [Azure CLI user commands](https://learn.microsoft.com/en-us/cli/azure/ad/user?view=azure-cli-latest) accept the object ID, letting you avoid a broad name-based cleanup.

* * *

_**Warning: The following commands delete the selected user and the lab application registration. Run them only after matching both IDs to this tutorial’s resources, and skip deletion for any resource you intend to retain.**_

* * *

Delete the verified test user:

```bash
az ad user delete --id "$TEST_USER_ID"
```

Run the preceding `az ad user show` query again. The deleted account should no longer resolve as an active user. Investigate authentication or permission errors before confirming deletion.

Delete the disposable application using its recorded client ID:

```bash
az ad app delete --id "$AZURE_CLIENT_ID"
```

Verify removal with `az ad app show --id "$AZURE_CLIENT_ID"`. [Application deletion](https://learn.microsoft.com/en-us/cli/azure/ad/app?view=azure-cli-latest#az-ad-app-delete) removes the registration; stop the process before teardown so it cannot continue using a previously acquired token. Remove its MCP configuration and the generated certificate files from your protected lab directory after you have finished recording the result.

### Set the Boundary for a Shared Service

For a shared service, replace the process-local provenance map with durable operation records and connect approval to the exact requested account, SKU, and caller. Add authenticated HTTP ingress only after implementing the [MCP authorization requirements](https://github.com/modelcontextprotocol/python-sdk/blob/v2.3.0/docs/run/authorization.md). Validate incoming token signature, issuer, audience, expiry, tenant, and caller permissions, and obtain Graph tokens separately. [MCP security guidance](https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices) rejects token passthrough.

You now have explicit places to enforce account naming, location policy, and product selection, plus observable creation and licensing stages. Keep those controls in Python as you add agent prompts. Before enabling employee onboarding, run the tenant checks, implement secure account activation, and prove that your persisted recovery records survive a server restart. Have the agent return the recorded user ID and verification state with each completed request.

Share this article

[Share on X](https://twitter.com/intent/tweet?url=https%3A%2F%2Fadamtheautomator.com%2Fmicrosoft-graph-api-mcp-server-provisioning%2F&text=Microsoft%20Graph%20API%20MCP%20Server%3A%20Automate%20User%20Provisioning)[Share on Facebook](https://www.facebook.com/sharer/sharer.php?u=https%3A%2F%2Fadamtheautomator.com%2Fmicrosoft-graph-api-mcp-server-provisioning%2F)[Share on LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fadamtheautomator.com%2Fmicrosoft-graph-api-mcp-server-provisioning%2F)

## Related Posts

![](https://adamtheautomator.com/wp-content/uploads/publisher/4065d9c85b2b81bf9605cb37d967f788/a9812525518a5125a43d2341858253da973f79467a0eba39ef631295b5a8807b.png)

### [Entra Agent IDs: How to Stop AI Privilege Escalation](/entra-agent-id-privilege-escalation/)

Map the four objects behind a Microsoft Entra Agent ID, follow four documented paths to tenant control, and apply eight controls in risk order.

![](https://adamtheautomator.com/wp-content/uploads/publisher/3ec5d9c85b2b81178e9ac5b3109d7e40/a17dc7e937f939221a5e25aadd5f5749c67f0e262d984e9b032a40d083fe81f4.png)

### [Find Expiring App Registration Secrets Before an Outage](/find-expiring-app-registration-secrets/)

Report expiring app registration secrets and certificates with PowerShell and Microsoft Graph, route each one to its owner, and rotate it safely.

![](https://adamtheautomator.com/wp-content/uploads/publisher/2e05d9c85b2b81c080b8d45ec1cdfbb3/71dd588b2369d52e1695c95c1493383c6c0ed2adb4aac7eba04c1ab324f3a6a6.webp)

### [Stop Service Principal Sprawl in Entra ID with PowerShell](/stop-service-principal-sprawl-entra-id-powershell/)

Inventory every app registration and service principal, then automate ownership, rotation, and safe disablement with PowerShell and Microsoft Graph.

## Categories

*   [IT Ops](/category/it-ops/)
*   [Cloud](/category/cloud/)
*   [DevOps](/category/devops/)
*   [Home Ops](/category/home-ops/)
*   [Information Security](/category/infosec/)
*   [Software Development](/category/software-development/)

## Site

*   [Home](/)
*   [Tutorials](/tutorials/)
*   [Instructors](/author/)
*   [Advertising](/advertising/)
*   [Recommended Resources](/resources/)
*   [About Adam](/about-adam/)

Copyright 2026© ATA Learning | [Privacy Policy](/privacy/)
