Skip to content

Reference

Auth model

Two modes, selected by which environment variables are set:

Mode When Env vars
app-only All three set ENTRAADM_TENANT_ID, ENTRAADM_CLIENT_ID, ENTRAADM_CLIENT_SECRET
azure-cli None set Uses the current az login session

Setting one or two of the three app-only variables raises a configuration error at startup rather than silently falling back to a different mode.

Optional: ENTRAADM_MAX_PAGES_DEFAULT (1-50, default 50) sets the default page cap for the log-scanning tools when a tool call doesn't pass max_pages explicitly.

Optional: ENTRAADM_DEADLINE (seconds, default 45; 0 disables) is the wall-clock budget of one tool call. A hosted MCP client cuts a call off at about 60 s, so a scan stops at the budget, each request's timeout is capped at the time left, and the pages fetched so far come back with capped=true. daily_brief shares one budget between its two sections. directory_audits also takes category (for example UserManagement) to filter on the Graph side.

Required Graph permissions

Tool(s) Permission
get_user (base fields) User.Read.All
signin_logs, signin_failure_stats, signin_success_stats, signin_by_ip, directory_audits, get_user's sign_in_activity field AuditLog.Read.All (app-only) or the Reports Reader directory role (delegated)
get_user_auth_methods UserAuthenticationMethod.Read.All (app-only only)

Tools

health_check()

No parameters. Returns {service, version, status, auth_mode, graph, signin_probe}. graph probes basic Graph reachability (GET /users with $top=1 -- needs only User.Read.All); signin_probe additionally checks sign-in log access. status is "healthy" when both succeed, "degraded" when Graph is reachable but sign-in log access is not, "error" when Graph itself is unreachable or auth is misconfigured. graph/signin_probe are each {auth: "ok"|"error", detail: str|null}.

get_user(upn)

Account lifecycle state: accountEnabled, userType, creation/last password-change timestamps, on-premises sync status, resolved license names, and (if AuditLog.Read.All/Reports Reader is available) sign_in_activity. A nonexistent account returns {"found": false, "user_principal_name": upn} rather than an error. licenses_capped: true appears only when the SKU catalog scan was cut short before resolving one of this account's own licenses -- when present, one or more licenses entries is a raw skuId rather than a friendly name.

signin_logs(user, hours=24, result="failure", top=25, max_pages=None)

One user's sign-in events. result: "failure" (default, the common case), "success", or "all" — filtered client-side, since Graph cannot filter sign-ins on status/errorCode server-side. Each event's error_code is annotated with error_code_meaning (e.g. 50126 → "invalid credentials (wrong password)") from a hand-maintained AADSTS code table. hours clamped to 1-720 (30 days — Entra ID P1's sign-in log retention). capped=true means the page budget ran out (or top was reached) before the whole window was scanned — a low match count alongside capped=true means "not found within the budget," not "doesn't exist."

signin_failure_stats(hours=24, max_pages=None)

Tenant-wide failure aggregation: top AADSTS error codes (annotated), top failing users, top applications, and top source IPs. spray_suspects lists any IP with failed sign-ins against 5 or more distinct users — a pattern Entra's per-account smart lockout does not catch on its own. hours clamped as above.

signin_success_stats(hours=24, max_pages=None, min_distinct_users=2)

Tenant-wide successful sign-in aggregation by source IP — the companion to signin_failure_stats: that one shows who is being attacked, this one shows whether anyone got in. shared_ips lists the IPs with successes for min_distinct_users or more distinct accounts, most-shared first (up to 50 IPs, shared_ips_capped when more qualified; account names up to 25 per IP, client apps, countries, first/last seen); legacy_auth_users lists the accounts that succeeded over a legacy protocol (Authenticated SMTP, IMAP4, POP3, …), which carry no MFA. A campus NAT or a VDI farm also puts many accounts behind one IP, so exclude your own egress ranges before reading shared_ips as a breach. Same log walk and capped semantics as signin_failure_stats; like it, this scans interactive sign-ins only (every legacy-protocol authentication is logged as interactive; non-interactive token refreshes are not counted).

signin_by_ip(ip, hours=24, result="all", top=50, max_pages=None)

Every sign-in from one source IP — the follow-up to a spray_suspects or shared_ips hit. Graph filters on ipAddress server-side, so this is one cheap query rather than a log walk. users summarises the IP per account (successes, failures, first/last seen, up to 50); events lists the newest top entries matching result ("all" / "success" / "failure"), each with the account name and the same AADSTS annotation as signin_logs. events_truncated means more matching rows were read than top returns (users still counts them). ip must parse as an IPv4/IPv6 address.

directory_audits(user=None, hours=24, top=25, max_pages=None)

Directory audit trail: who did what (block/unblock, attribute edits), and when. user, when given, matches audits where that account is either the initiator or a target resource — Graph only supports server-side filtering on the initiator, so this fetches the window and matches both sides client-side (a busy window may need a larger max_pages to find one person's audits).

get_user_auth_methods(upn)

Registered authentication methods for one account. mfa_registered is true iff at least one non-password method is registered (Authenticator app, phone, FIDO2 key, Windows Hello, temporary access pass, software OATH, or platform credential/passkey). Needs UserAuthenticationMethod.Read.All (app-only); not available under az login-based delegated auth in a typical role assignment. A nonexistent account returns {"found": false, "user_principal_name": upn}, same as get_user.

daily_brief(hours=24, max_pages=None, samples=10)

One-call summary combining signin_failure_stats and directory_audits, with a compact summary on top. A permission failure in one section degrades only that section — the other still returns in full. Runs both sections synchronously in one tool call; samples is currently unused (reserved).

Errors

Every tool's entry point catches configuration and Graph-client errors and returns {"error": "..."} rather than raising, so a caller always gets a dict back. A GraphPermissionError additionally sets missing_permission naming the actual Graph permission or directory role that endpoint needs. A result built from a paged Graph collection always carries capped: bool — a page-budget cutoff is never indistinguishable from "the window was fully scanned."

CLI

entraadm-mcp --version   # print version
entraadm-mcp --check     # resolve auth, probe Graph + sign-in log reachability, exit 0 (or 1 on config error)