Skip to main content
Glama
taitson

entra-mcp

by taitson
README.md
# entra-mcp

[![CI](https://github.com/taitson/entra-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/taitson/entra-mcp/actions/workflows/ci.yml)
[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
[![Licence: MIT](https://img.shields.io/badge/licence-MIT-green)](LICENSE)

An MCP server that gives an AI agent read access to Microsoft 365 — and refuses to run with more permission than its own tools require.

```bash
uvx --from git+https://github.com/taitson/entra-mcp entra-mcp serve
```

That command works right now, with no Azure tenant, no app registration and no credentials. It serves a fictional 25-person tenant so you can see what the tools do before deciding whether to point them at a real one.

---

## The problem this exists for

Every guide to connecting an agent to Microsoft 365 tells you to register an app with `Directory.ReadWrite.All` and move on. That hands the agent — and anyone who reaches its credential — the ability to delete your tenant, so that it can list users.

Nobody wants that. It happens because the permission lives in the Azure portal, far away from the code, and nothing checks that the two agree. Working out the minimum set by hand means reading the docs for every endpoint you call, and then re-checking it every time a tool is added.

`entra-mcp` moves the permission next to the tool that needs it:

```python
FIND_STALE_ACCOUNTS = declare(
    "entra_find_stale_accounts",
    {"User.Read.All", "AuditLog.Read.All"},
    because="Cross-references the user list with sign-in activity.",
)
```

From that, two things follow. `entra-mcp scopes` prints exactly what to grant, so the app registration is built from the code rather than guessed at. And on `--live` the server checks the other direction before serving anything: it reads the `roles` claim of its own access token — the permissions actually granted and consented — and refuses to start when the app holds more than the enabled tools need.

```
$ entra-mcp serve --live

  ✓ 4 tools enabled (read-only)
  ✓ scopes required: User.Read.All, Directory.Read.All,
      Organization.Read.All, AuditLog.Read.All

  ✗ REFUSED: the app holds 2 permissions beyond what these tools use
      · Directory.ReadWrite.All  (no enabled tool requires it)
      · Mail.Send                (no enabled tool requires it)

  Remove them from the app registration, or pass --allow-excess
  to start regardless (the permission stays granted either way).
```

A missing permission is reported separately and cannot be waived at all — over-permissioned is a governance problem, under-permissioned is a broken server.

A permission no enabled tool declares is not convenience. It is standing authority that nothing in the running process will ever exercise, and that an attacker who reaches the credential inherits in full.

---

## What it answers

The tools are chosen to compose, not to mirror the Graph API. The question they exist for is one every IT manager has and none can answer quickly:

> **"Which licences are we paying for that nobody has used in 90 days?"**

Answering it by hand means the user list, the sign-in logs and the purchased SKUs, joined three ways. Which is why nobody does it, and why licences quietly accumulate on people who left the company.

```
> Which licences are we paying for that nobody has used in 90 days?

  6 accounts, 9 reclaimable seats:

  · 2 disabled accounts still holding licences
      Cameron White    287 days   Microsoft 365 E5
      Bianca Pisani    341 days   Microsoft 365 E5, Power BI Pro

  · 3 active but idle over 90 days
      Lee Gu           204 days   Microsoft 365 E5, Power BI Pro
      Alland Deyoung   156 days   Microsoft 365 E3
      Irvin Sayers     118 days   Microsoft 365 E5, Power BI Pro

  · 1 service account, never signed in interactively
      Svc-BackupAgent            Microsoft 365 E3

  By product:  E5 ×4   Power BI Pro ×3   E3 ×2

  Separately, 9 purchased seats are assigned to nobody at all.
```

---

## Tools

| Tool | Answers | Graph permission |
|---|---|---|
| `entra_list_users` | Who is in the tenant, filtered by department or account state | `User.Read.All` |
| `entra_get_user` | One person: profile, manager, groups, licences | `User.Read.All`, `Directory.Read.All` |
| `entra_get_license_usage` | Seats bought vs. seats assigned, per SKU | `Organization.Read.All` |
| `entra_find_stale_accounts` | Idle accounts and the licences they hold | `User.Read.All`, `AuditLog.Read.All` |

All read-only. Write tools are planned for v1.0, disabled by default, opt-in per tool, with a dry-run mode.

Run `entra-mcp scopes` to print this list with the reason for each permission.

---

## Demo mode

```bash
entra-mcp serve          # demo is the default
entra-mcp serve --demo
```

The fictional Contoso tenant has 25 accounts, five licence SKUs, two disabled-but-licensed accounts and one service account that has never signed in — enough shape for every tool to return something interesting.

Sign-in dates are stored as *days ago* and resolved against the clock at load. A fixture with absolute dates starts lying the week after it is written: every account drifts past the staleness threshold until the demo that showed six idle licences shows twenty-five.

Demo mode is not only for evaluation. It keeps the test suite offline, free and fast, which is what lets it run on every push.

---

## Live mode

### 1. Register an app

Azure portal → **Microsoft Entra ID** → **App registrations** → **New registration**.

Under **API permissions**, add these as **Application permissions** (not Delegated), then click **Grant admin consent**:

```
User.Read.All
Directory.Read.All
Organization.Read.All
AuditLog.Read.All
```

Under **Certificates & secrets**, create a client secret and copy the value immediately — it is shown once.

> `AuditLog.Read.All` also requires **Microsoft Entra ID P1** or higher on the tenant. Without it, `signInActivity` does not exist in Graph at all and `entra_find_stale_accounts` will tell you so rather than returning a misleading empty result.

### 2. Provide the credentials

```bash
cp .env.example .env   # then fill in the three values
entra-mcp serve --live
```

`.env` is gitignored. The server reads `ENTRA_TENANT_ID`, `ENTRA_CLIENT_ID` and `ENTRA_CLIENT_SECRET` from the environment, so a secret manager works just as well.

Every response is tagged `"mode": "demo"` or `"mode": "live"`. An agent that cannot tell fictional data from a real tenant will report fictional licence counts as fact, and so will the person reading its answer.

---

## The console

```bash
entra-mcp console
```

Opens on `http://127.0.0.1:8765`. No tenant, no credentials, no MCP client — the fastest way to see what the server does.

It is not an admin dashboard, and adding one would put this project in a crowded category it has no business competing in. It is the README's argument on a screen: every tool beside the Graph permission it declares, the union your app registration should hold, and — with `--live` — what the credential *actually* holds and where the two disagree.

- **Tools**, each showing the permissions it costs you
- **Permissions**, with the verdict: least privilege holds, over-permissioned, or missing a scope. In demo mode it says there is nothing to audit rather than showing a green tick it has not earned.
- **Run any tool**, with a form built from its schema
- **The audit trail**, live, so you can watch what a call records — and confirm it does not record the directory

```bash
entra-mcp console --live --port 9000
```

The same startup audit as `serve`: an over-permissioned credential refuses to open the console too.

> The console has **no authentication** and binds to `127.0.0.1`. It is a local tool for one operator on one machine. Binding it elsewhere with `--host` exposes an unauthenticated view of your directory, and the server says so when you try.

---

## Audit trail

```bash
entra-mcp serve --live --audit-trail ./entra-mcp.jsonl
```

One JSON line per tool call:

```json
{"at":"2026-08-30T16:12:04+00:00","tool":"entra_find_stale_accounts","mode":"live",
 "arguments":{"days":90,"licensed_only":true},"outcome":"ok","durationMs":412,
 "summary":{"accountsFound":6,"reclaimableSeats":9}}
```

"Who told the agent to disable that account?" is the question that stops agent adoption in any organisation with a compliance function. This is what makes the answer *"here is the line"*.

**What it deliberately does not record: the response body.** A single `entra_list_users` reply carries names, job titles and licence assignments for the whole tenant; writing that on every call would turn an audit log into a shadow copy of the directory, sitting outside every access control that protects the real one. The line records what was *asked* and a summary of what came back — enough to reconstruct behaviour, not enough to be a breach on its own.

Argument values are truncated, and any argument whose name looks like a credential is masked. Off unless a path is given; `ENTRA_AUDIT_TRAIL` works too.

---

## Use it with Claude Desktop

```json
{
  "mcpServers": {
    "entra": {
      "command": "uvx",
      "args": ["--from", "git+https://github.com/taitson/entra-mcp", "entra-mcp", "serve", "--demo"]
    }
  }
}
```

Swap `--demo` for `--live` once the credentials are in place.

---

## Development

```bash
git clone https://github.com/taitson/entra-mcp
cd entra-mcp
uv venv && uv pip install -e ".[dev]"
uv run pytest
```

99 tests, all of them without a tenant, credentials or network access.

On Windows, see Troubleshooting above before running `pytest` directly.

Layout:

```
src/entra_mcp/
  scopes.py    permission declarations, one per tool
  console.py   the local page — Starlette, no new dependency
  audit.py     reads the token's roles claim and compares it to the above
  graph.py     one client interface, two backends (fixture and live HTTP)
  tools.py     tool logic — plain functions, no MCP imports
  trail.py     the audit trail, and what it refuses to write
  server.py    MCP wiring: call, record, serialise
```

`tools.py` never imports the MCP SDK, and `graph.py` never imports `tools.py`. That is what keeps the logic testable without a transport and the backends swappable without touching the tools.

---

## Troubleshooting

**Windows: `foi bloqueado pela política do Device Guard` / "blocked by your organization"**

Smart App Control, on by default in fresh Windows 11 installs, blocks freshly created unsigned executables — which is exactly what `pip` writes into `Scripts\`. The message mentions "your organization" even when no organisation is involved.

Call the module instead of the shim:

```
python -m pytest -q
python -m entra_mcp scopes
python -m entra_mcp serve --demo
```

`python.exe` is signed and allowed; the generated `.exe` wrappers are not. Do not turn Smart App Control off to work around this — it cannot be re-enabled without reinstalling Windows.

**`AuditLog.Read.All` works but `entra_find_stale_accounts` returns nothing useful**

`signInActivity` requires Microsoft Entra ID P1 or higher. Without it the field does not exist in Graph at all. The tool says so rather than returning an empty list that reads like "everyone is active".

**The server starts but every tool returns a 403**

Permissions were added to the app registration but admin consent was never granted. In the portal, the API permissions page shows a warning triangle until it is.

---

## Roadmap

**v0.1** — the four read tools, demo mode, tests. *(done)*

**v0.2** — startup privilege audit; JSONL audit trail that summarises rather than copies; the local console. *(you are here)*

**v0.3** — `entra_list_devices` (Intune compliance state), `entra_list_group_members`, `entra_get_signin_activity`.

**v1.0** — write tier behind per-tool opt-in with dry-run; an evaluation harness that checks the agent picks the right tool *and* that the guardrails hold when it does not; Docker image; PyPI.

---

## Licence

MIT.

TDQS

A4.5/5.0

Scored across 4 tools

Disambiguation5/5

Each tool targets a distinct resource or question: license SKU counts, full user list, a single user's details, and stale accounts. The one potentially overlapping pair (license usage vs. stale accounts) is explicitly disambiguated in the descriptions.

Naming Consistency5/5

All tools use a consistent entra_ prefix followed by a clear verb_noun pattern: get_license_usage, list_users, get_user, find_stale_accounts. Verb choice maps predictably to collection vs. single-item vs. filtered lookup.

Tool Count5/5

Four tools is a well-scoped set for a read-only Entra user and license audit server. Each tool covers a meaningful slice of the domain without redundancy or bloat.

Completeness4/5

The read-side is well covered: list users, get user details, summarize license usage, and find stale accounts. The main gap is the lack of any write or management operations, such as assigning/reclaiming licenses or disabling accounts, though the tool set appears intentionally focused on analysis rather than changes.

Maintenance

ActivityMaintained
ResponsivenessNo issues