Entra Identity Posture MCP
README.md
# Entra Identity Posture MCP
[](https://github.com/henrymbuguakiarie/entra-identity-posture-mcp/actions/workflows/ci.yml)
An Agentic Security and Governance [MCP](https://modelcontextprotocol.io/) server built with **FastMCP** and the **Microsoft Graph API** for auditing Microsoft Entra ID app registrations and Conditional Access policies against Zero-Trust principles — and generating dry-run remediation scripts an AI agent (or human) can review and run.
> **Status:** Alpha — under active development. Interfaces and tool surfaces may change.
## Overview
This server exposes Microsoft Entra ID (Azure AD) security posture data as a full MCP surface — **Tools**, a **Resource**, and a **Prompt** — so that AI agents (e.g., Claude Desktop, VS Code Copilot Chat) can:
- Audit app registrations for expiring/long-lived credentials, over-privileged Graph permissions, insecure redirect URIs, and risky multi-tenant configurations
- Scan Conditional Access policies for admin MFA exclusions and policies stuck in report-only mode
- Run both scans concurrently with a single `run_posture_scan` call, with optional severity/rule/app filtering, when an agent wants one combined pass instead of two separate tool calls
- Generate a Markdown Zero-Trust security report plus ready-to-run (dry-run only) Azure CLI / Microsoft Graph PowerShell remediation commands
- Query the most recent scan results directly as an MCP Resource, without re-invoking a tool
- Kick off a guided triage workflow via a predefined MCP Prompt
The server is **read-only** against Microsoft Graph (`Application.Read.All`, `Policy.Read.All`). It never calls a Graph write endpoint — remediation tools only _generate_ text commands for a human or CI pipeline to execute.
### Architecture
```mermaid
flowchart LR
subgraph Client["MCP Client (Claude Desktop / VS Code Copilot Chat)"]
A[AI Agent]
end
subgraph Server["entra-identity-posture-mcp (FastMCP, stdio)"]
T1[Tool: audit_app_registrations]
T2[Tool: scan_conditional_access_gaps]
T5[Tool: run_posture_scan]
T3[Tool: generate_remediation_plan]
T4[Tool: revoke_or_disable_app_registration]
R1[Resource: entra://posture/latest]
P1[Prompt: security_triage_prompt]
Rules[Rules Engine\napp_registration_rules.py\nconditional_access_rules.py]
Cache[(In-memory\nposture cache\nper category)]
end
subgraph Graph["Microsoft Graph API"]
G1[/applications/]
G2[/servicePrincipals/]
G3[/identity/conditionalAccess/policies/]
end
Auth[auth.py\nMSAL cert-based\nConfidentialClientApplication]
A -->|JSON-RPC over stdio| T1 & T2 & T5 & T3 & T4 & R1 & P1
T1 --> Rules
T2 --> Rules
T5 --> Rules
T1 -->|GET| G1 & G2
T2 -->|GET| G3
T5 -->|GET| G1 & G2 & G3
T1 & T2 & T5 -.->|auth token| Auth
Auth -->|client cert| G1
T1 & T2 & T5 --> Cache
R1 --> Cache
T3 -->|renders| Report[[security_report.md.j2]]
```
## Requirements
- Python 3.12+
- A Microsoft Entra ID app registration configured with a **certificate credential** (client secrets are not supported by `auth.py`)
- Admin-consented Microsoft Graph **application** permissions: `Application.Read.All` and `Policy.Read.All`
## 1. Clone the repository
```bash
git clone https://github.com/henrymbuguakiarie/entra-identity-posture-mcp.git
cd entra-identity-posture-mcp
```
## 2. Install dependencies
Install with [uv](https://docs.astral.sh/uv/) (recommended):
```bash
uv sync
```
Or install with pip:
```bash
pip install -e .
```
Include test and lint tooling for development:
```bash
uv sync --group dev
```
## 3. Create the Entra app registration and certificate credential
Authenticate the server with a **certificate**, not a client secret — `auth.py` only supports MSAL's certificate-based confidential client flow. Complete these steps once, manually; the server does not automate app registration or admin consent.
### 3.1 Register the app
1. Open the [Entra admin center](https://entra.microsoft.com) and go to **Identity → Applications → App registrations**.
2. Select **New registration**, name it (e.g. `entra-identity-posture-mcp`), keep the default single-tenant account type, and select **Register**.
3. Copy the **Application (client) ID** and **Directory (tenant) ID** from the app's **Overview** page — you'll need both for `.env`.
### 3.2 Generate a certificate
Generate the certificate on the machine that will run the server, so the private key never leaves your workstation.
**Windows (PowerShell):**
```powershell
# Generate a self-signed certificate and store it in your user certificate store
$cert = New-SelfSignedCertificate `
-Subject "CN=entra-identity-posture-mcp" `
-CertStoreLocation "Cert:\CurrentUser\My" `
-KeyExportPolicy Exportable `
-KeySpec Signature `
-KeyLength 2048 `
-NotAfter (Get-Date).AddYears(1)
# Export the public certificate to upload to Entra
Export-Certificate -Cert $cert -FilePath "$HOME\entra-mcp-cert.cer"
# Export the private key as a password-protected PFX
$securePwd = Read-Host -Prompt "Set a temporary PFX password" -AsSecureString
Export-PfxCertificate -Cert $cert -FilePath "$HOME\entra-mcp-cert.pfx" -Password $securePwd
```
Convert the PFX to the PEM private key format `auth.py` expects — this requires [OpenSSL](https://openssl.org/), which ships with Git for Windows at `C:\Program Files\Git\mingw64\bin\openssl.exe`:
```bash
openssl pkcs12 -in ~/entra-mcp-cert.pfx -nocerts -nodes -out ~/entra-mcp-cert.key.pem
```
Delete the PFX once you have the PEM file — you no longer need it:
```powershell
Remove-Item "$HOME\entra-mcp-cert.pfx" -Force
```
**macOS/Linux (OpenSSL, cross-platform):**
```bash
# Generate a private key and matching self-signed public certificate in one step
openssl req -x509 -newkey rsa:2048 -keyout entra-mcp-cert.key.pem -out entra-mcp-cert.cer \
-days 365 -nodes -subj "/CN=entra-identity-posture-mcp"
```
Either path produces two files:
- `entra-mcp-cert.cer` — the **public** certificate. Upload this one to Entra.
- `entra-mcp-cert.key.pem` — the **private** key. Keep this file local and never commit it (see [.gitignore](.gitignore)); `ENTRA_CERT_PATH` points to it.
### 3.3 Upload the certificate
1. Open **Certificates & secrets → Certificates** on your app registration.
2. Select **Upload certificate** and choose `entra-mcp-cert.cer` — upload only the public certificate, never the private key.
3. Copy the certificate's **Thumbprint** after the upload completes — you'll need it for `.env`.
### 3.4 Grant API permissions
1. Open **API permissions → Add a permission → Microsoft Graph → Application permissions**.
2. Add `Application.Read.All` and `Policy.Read.All`, then select **Add permissions**.
3. Select **Grant admin consent for <tenant>** and confirm. Both permissions must show a green check under **Status** before the server can call Graph.
## 4. Configure environment variables
The server authenticates to Microsoft Graph via [MSAL](https://learn.microsoft.com/entra/msal/python/) certificate-based confidential client auth. Copy [.env.example](.env.example) to `.env`:
```bash
cp .env.example .env
```
Fill in the values you collected in step 3:
| Variable | Description |
| -------------------------- | --------------------------------------------------------------------------------------- |
| `ENTRA_TENANT_ID` | The **Directory (tenant) ID** from the app registration's Overview page |
| `ENTRA_CLIENT_ID` | The **Application (client) ID** from the app registration's Overview page |
| `ENTRA_CERT_PATH` | Path to the PEM-encoded **private key** file (`entra-mcp-cert.key.pem`), not the `.cer` |
| `ENTRA_CERT_THUMBPRINT` | Thumbprint of the certificate you uploaded to the app registration |
| `IMMINENT_EXPIRATION_DAYS` | Days-until-expiry threshold for the `IMMINENT_EXPIRATION` rule (default `30`) |
| `EXCESSIVE_LIFESPAN_DAYS` | Max credential lifespan in days before flagging `EXCESSIVE_LIFESPAN` (default `180`) |
> **Note:** `ENTRA_CERT_PATH` must point to the PEM **private key**, not the `.cer` file you uploaded to Entra — `auth.py` reads this file and passes its contents to MSAL as the client credential.
## 5. Run the server
Start the MCP server directly over stdio:
```bash
uv run entra-posture-mcp
```
### VS Code (`mcp.json`)
```jsonc
{
"servers": {
"entra-identity-posture": {
"command": "uv",
"args": ["run", "entra-posture-mcp"],
"cwd": "${workspaceFolder}",
},
},
}
```
### Claude Desktop (`claude_desktop_config.json`)
```jsonc
{
"mcpServers": {
"entra-identity-posture": {
"command": "uv",
"args": [
"run",
"--directory",
"C:\\Work\\Automation\\entra-identity-posture-mcp",
"entra-posture-mcp",
],
},
},
}
```
### MCP surface reference
| Kind | Name | Description |
| -------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Tool | `audit_app_registrations` | Scans app registrations for expiring/long-lived secrets, risky permissions, and insecure redirect URIs. Returns structured findings (`metadata` + `issues`) plus a text `summary` |
| Tool | `scan_conditional_access_gaps` | Scans Conditional Access policies for admin MFA exclusions and report-only status. Returns structured findings plus a text `summary` |
| Tool | `run_posture_scan` | Runs both scans above concurrently and returns one merged, optionally severity/rule/app-filtered result. Updates both cache categories |
| Tool | `generate_remediation_plan` | Renders a Markdown Zero-Trust report + dry-run CLI/PowerShell snippets from findings |
| Tool | `revoke_or_disable_app_registration` | Generates a dry-run Azure CLI/PowerShell command to disable sign-in, rotate a password or certificate credential (type-specific), remove a credential, or remove a permission |
| Resource | `entra://posture/latest` | Cached JSON from the most recent scan of each category (app registrations, Conditional Access), queryable without re-invoking a tool |
| Prompt | `security_triage_prompt` | Predefined Zero-Trust triage prompt to prioritize findings and recommend fixes |
### Sample JSON-RPC request/response
MCP clients talk to the server over stdio using [JSON-RPC 2.0](https://www.jsonrpc.org/specification) — every tool call is one request/response pair on stdin/stdout. Here's what actually crosses the wire when a client calls `revoke_or_disable_app_registration` (captured against this server):
**Request** (client → server, on stdin):
```jsonc
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "revoke_or_disable_app_registration",
"arguments": {
"app_id": "abc-123",
"action": "disable_sign_in",
},
},
}
```
**Response** (server → client, on stdout):
````jsonc
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{
"type": "text",
"text": "🔒 Dry-Run Remediation Output for App ID 'abc-123':\n\n```bash\n# PowerShell (MgGraph): Disable user sign-in\nUpdate-MgServicePrincipal -ServicePrincipalId abc-123 -AccountEnabled:$false\n```\n*(Note: Read-only mode active. Run the script manually or via CI pipeline to execute).*",
},
],
"structuredContent": {
"result": "🔒 Dry-Run Remediation Output for App ID 'abc-123':\n\n```bash\n# PowerShell (MgGraph): Disable user sign-in\nUpdate-MgServicePrincipal -ServicePrincipalId abc-123 -AccountEnabled:$false\n```\n*(Note: Read-only mode active. Run the script manually or via CI pipeline to execute).*",
},
"isError": false,
},
}
````
The MCP Inspector CLI commands in [step 6](#6-verify-the-server) print just the `result` payload — reproduce the raw envelope above by driving the server directly with the [MCP Python SDK client](https://github.com/modelcontextprotocol/python-sdk), or watch stdin/stdout while a client like Claude Desktop or VS Code Copilot Chat drives it.
`revoke_or_disable_app_registration`'s `action` argument distinguishes password vs. certificate credentials, so the generated command matches the credential type flagged by a scan finding's `evidence.credential_type`:
```jsonc
// action: "rotate_password_credential"
"az ad app credential reset --id abc-123 --append"
// action: "rotate_certificate_credential"
"az ad app credential reset --id abc-123 --append --create-cert"
```
`--append` is always included — it adds a new credential alongside existing ones for zero-downtime rotation, instead of the Azure CLI's destructive default of clearing all existing credentials.
### Example agent workflow
```
User: Run a Zero-Trust audit on my Entra tenant and tell me what to fix first.
Agent: [calls run_posture_scan]
[calls generate_remediation_plan with the combined findings]
Agent: I found 2 CRITICAL and 3 HIGH severity issues:
1. [CRITICAL] "Vendor Sync App" has Directory.ReadWrite.All combined with a
multi-tenant (AzureADMultipleOrgs) sign-in audience.
→ Update-MgApplication -ApplicationId <id>
2. [CRITICAL] Conditional Access policy "Require MFA for Admins" excludes the
Global Administrator role from enforcement.
→ Get-MgIdentityConditionalAccessPolicy -ConditionalAccessPolicyId <id>
Full report and remaining dry-run commands are in the Markdown report above.
Review each command before running it — nothing has been changed in your tenant.
```
## 6. Verify the server
Before you wire the server into an LLM client, validate the JSON-RPC tool/resource/prompt schemas in isolation using the official [MCP Inspector CLI](https://github.com/modelcontextprotocol/inspector):
```bash
npx @modelcontextprotocol/inspector --cli uv run entra-posture-mcp --method tools/list
npx @modelcontextprotocol/inspector --cli uv run entra-posture-mcp --method resources/list
npx @modelcontextprotocol/inspector --cli uv run entra-posture-mcp --method prompts/list
# Exercise a tool that doesn't require live Graph credentials
npx @modelcontextprotocol/inspector --cli uv run entra-posture-mcp \
--method tools/call --tool-name revoke_or_disable_app_registration \
--tool-arg app_id=abc-123 --tool-arg action=disable_sign_in
```
Once you configure `.env` against a real test tenant, run the stdio entrypoint and invoke `audit_app_registrations` / `scan_conditional_access_gaps` / `run_posture_scan` to confirm known findings (an expiring secret, a risky permission, or a report-only Conditional Access policy) surface correctly — then repeat the workflow through Claude Desktop or VS Code Copilot Chat using the configs above.
> Live MCP Inspector CLI session against a real test tenant, captured verbatim (tenant/app IDs redacted). Scan tools return structured `metadata` + `issues` alongside a `summary` text field — shown below trimmed to the first/last finding for brevity. Note `evidence.credential_type` (`"certificate"` or `"password"`) on the `EXCESSIVE_LIFESPAN` finding, which drives which of `rotate_password_credential` / `rotate_certificate_credential` a matching `IMMINENT_EXPIRATION` finding would recommend:
>
> ```console
> $ npx @modelcontextprotocol/inspector --cli uv run entra-posture-mcp --method tools/call --tool-name audit_app_registrations
> {
> "content": [
> {
> "type": "text",
> "text": "{\n \"metadata\": {\n \"tenant_id\": \"248c1b45-...\",\n \"scanned_at\": \"2026-07-29T09:35:26.482128Z\",\n \"rule_version\": \"1.1\"\n },\n \"issues\": [\n {\n \"app_id\": \"fd0486bd-...\",\n \"app_name\": \"InsomniaWebApp\",\n \"severity\": \"HIGH\",\n \"rule_id\": \"DANGEROUS_REDIRECT_URI\",\n \"issue\": \"Insecure redirect URIs detected: http://localhost.\",\n \"evidence\": {\"redirect_uris\": [\"http://localhost\"]},\n \"remediation_action\": null,\n ... 3 more DANGEROUS_REDIRECT_URI findings ...\n },\n {\n \"app_id\": \"c712c7f1-...\",\n \"app_name\": \"entra-identity-posture-mcp\",\n \"severity\": \"MEDIUM\",\n \"rule_id\": \"EXCESSIVE_LIFESPAN\",\n \"issue\": \"Credential key_id '5e44aaeb-...' has an excessive lifespan of 365 days.\",\n \"evidence\": {\"key_id\": \"5e44aaeb-...\", \"credential_type\": \"certificate\", \"lifespan_days\": 365},\n \"remediation_action\": \"remove_credential\",\n \"remediation_params\": {\"app_id\": \"c712c7f1-...\", \"key_id\": \"5e44aaeb-...\"}\n }\n ],\n \"summary\": \"Found 6 app registration security issues:\\n\\n- [HIGH] InsomniaWebApp (fd0486bd-...): Insecure redirect URIs detected: http://localhost.\\n... 4 more ...\\n- [MEDIUM] entra-identity-posture-mcp (c712c7f1-...): Credential key_id '5e44aaeb-...' has an excessive lifespan of 365 days.\"\n}"
> }
> ],
> "isError": false
> }
>
> $ npx @modelcontextprotocol/inspector --cli uv run entra-posture-mcp --method tools/call --tool-name scan_conditional_access_gaps
> {
> "content": [
> {
> "type": "text",
> "text": "{\n \"metadata\": {\n \"tenant_id\": \"248c1b45-...\",\n \"scanned_at\": \"2026-07-29T09:11:33.227421Z\",\n \"rule_version\": \"1.1\"\n },\n \"issues\": [],\n \"summary\": \"✅ Conditional Access scan complete: All policies comply with Zero-Trust standards.\"\n}"
> }
> ],
> "isError": false
> }
>
> $ npx @modelcontextprotocol/inspector --cli uv run entra-posture-mcp --method tools/call --tool-name run_posture_scan
> {
> "content": [
> {
> "type": "text",
> "text": "{\n \"metadata\": {\n \"tenant_id\": \"248c1b45-...\",\n \"scanned_at\": \"2026-07-29T09:24:31.284144Z\",\n \"rule_version\": \"app:1.1;ca:1.1\"\n },\n \"issues\": [ ... 6 combined app-registration + Conditional Access findings ... ],\n \"summary\": \"Found 6 posture issues (of 6 total): ...\"\n}"
> }
> ],
> "isError": false
> }
> ```
>
> `severity` is a Pydantic `Literal["CRITICAL", "HIGH", "MEDIUM", "LOW"]` at the MCP schema boundary, so an invalid value on `run_posture_scan` fails validation before the scan runs — it never silently returns zero results:
>
> ```console
> $ npx @modelcontextprotocol/inspector --cli uv run entra-posture-mcp \
> --method tools/call --tool-name run_posture_scan --tool-arg severity=NOT_A_LEVEL
> {
> "content": [
> {
> "type": "text",
> "text": "Error executing tool run_posture_scan: 1 validation error for run_posture_scanArguments\nseverity\n Input should be 'CRITICAL', 'HIGH', 'MEDIUM' or 'LOW' [type=literal_error, input_value='NOT_A_LEVEL', input_type=str]\n For further information visit https://errors.pydantic.dev/2.13/v/literal_error"
> }
> ],
> "isError": true
> }
> ```
>
> A GIF/screenshot of the same workflow running through Claude Desktop or VS Code Copilot Chat will replace this transcript once captured.
## Development
Run tests:
```bash
uv run pytest
```
Lint and format:
```bash
uv run ruff check .
uv run ruff format .
```
Continuous integration runs `ruff check` and `pytest` on every push/PR via [.github/workflows/ci.yml](.github/workflows/ci.yml).
## Roadmap
Deliberately out of scope for v1:
- **Terraform file generation** (e.g. `azuread_application_password`, `azuread_application_pre_authorized`) for remediation — v1 only emits dry-run Azure CLI / PowerShell snippets.
- **Automated GitHub PR creation** for remediation changes — v1 leaves execution and change management entirely to the human/CI pipeline.
Both are fast-follow candidates now that v1 has been validated against a live tenant.
## License
MIT © [Henry Mbugua](https://github.com/henrymbuguakiarie)
TDQS
A3.6/5.0
Scored across 5 tools
Disambiguation5/5
Each tool has a distinct purpose: two separate scans, a combined scan, a report generator, and a remediation command generator. There is no functional overlap.
Naming Consistency5/5
All tool names use a consistent verb_noun pattern in snake_case (e.g., audit_app_registrations, generate_remediation_plan), making them predictable and readable.
Tool Count5/5
With 5 tools, the server is well-scoped for its purpose of assessing and remediating Entra ID posture without being too sparse or bloated.
Completeness4/5
The tools cover auditing, combined scanning, remediation planning, and disabling/revoking registrations. Missing a tool to execute remediation commands directly is a minor gap, but the set is largely complete.
Maintenance
ActivitySlowing
ResponsivenessNo issues