| health_checkA | Fleet-standard health probe: service/version/status plus two independent Graph probes. graph confirms Microsoft Graph is reachable at all (GET /users
with $top=1 -- needs only User.Read.All, the minimum permission
every deployment of this server needs anyway). signin_probe additionally confirms the current
credential can read sign-in logs -- the permission every other tool here
except get_user depends on. Both probes always run, independently of
each other: a tenant that has AuditLog.Read.All but not (yet) the
baseline User.Read.All would otherwise have this report "Graph
unreachable" -- a fabricated diagnosis, since Graph plainly is
reachable if the other probe succeeds. status is derived from the
two outcomes: healthy when both succeed, degraded when exactly
one does (Graph is reachable but some permission is missing), error
only when neither does.
Read-only. Always returns the same keys regardless of outcome (detail
is null on success, a translated message on failure), so a caller never
has to branch on which keys are present. sites reports the optional ENTRAADM_SITES_INI table
(configured, ranges, error). A configured file that fails to
load is an error here and leaves status no better than
degraded: without it every internal IP would be reported as an
external spray suspect.
arm is a third, informational probe (Azure Resource Manager
reachable, and how many subscriptions this credential can see). It
never moves status: Azure RBAC is optional for this server, and a
deployment used only for Entra triage must not sit at degraded
forever because nobody granted it a Reader role. It runs last under its
own short budget (_ARM_PROBE_SECONDS) so an unresponsive ARM cannot
push this whole call past the client's tool-call timeout. The budget
bounds the request, not the first token acquisition (azure-identity is
synchronous; in azure-cli mode it shells out to az), which is the
same limit every tool here has.
|
| get_userA | One account's identity/lifecycle state -- the first thing to check on any triage report. account_enabled=false means the account itself is the whole story;
stop there. A stale last_password_change_date_time alongside a fresh
"wrong password" complaint (AADSTS50126 in signin_logs) is the most
common on-the-ground pattern: the password changed or expired somewhere,
and a cached credential on one device is now stale.
on_premises_sync_enabled=true means this account is synced from an
on-premises directory (Entra Connect) -- Entra is a downstream copy of
its password via Password Hash Sync, not the source of truth.
licenses names are resolved from the tenant's SKU catalog
(/subscribedSkus, page budget from ENTRAADM_MAX_PAGES_DEFAULT);
licenses_capped: true appears only when that 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.
sign_in_activity needs an additional Graph read (AuditLog.Read.All
application permission, or -- for azure-cli auth -- the Reports Reader
directory role) beyond what the rest of this tool needs. If that
permission is missing, every other field above still returns and
sign_in_activity alone degrades to {"error": ..., "missing_permission": "AuditLog.Read.All"}.
A nonexistent account is a normal answer, not a tool failure: the result
is {"found": false, "user_principal_name": upn} rather than an
error key, so a typo'd UPN in a triage report cannot be mistaken for
this tool being broken. Read-only (User.Read.All application permission, or an equivalent
delegated read). Requires an exact userPrincipalName, not a display name
or partial match. Args:
upn: The account's userPrincipalName, e.g. "user@example.edu". |
| signin_logsA | One user's recent sign-in events, AADSTS-annotated. The most direct answer to "why can't this person log in": each entry's
error_code_meaning translates the raw AADSTS code (e.g. 50126 ->
"invalid credentials (wrong password)") so triage rarely needs a second
lookup. result filters client-side after the Graph fetch (Graph
cannot filter sign-ins on status/errorCode server-side): "failure" (the
default) keeps only failed attempts, "success" keeps only clean ones,
"all" keeps everything. Because the filter is client-side, this walks pages until it has
collected top matching entries or exhausts max_pages -- a mostly-
successful user can otherwise mean paging through hundreds of rows to
find a handful of failures. 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 is evidence of "no more found within the
budget", not "no more exist". Read-only (AuditLog.Read.All application permission, or -- for azure-cli
auth -- the Reports Reader directory role). Entra ID P1 retains sign-in
logs for 30 days; hours beyond that returns an empty result, not an
error. Args:
user: The account's userPrincipalName.
hours: How far back to look, clamped to [1, 720] (30 days).
result: "failure" (default), "success", or "all".
top: Maximum matching entries to return, clamped to [1, 500].
max_pages: Page budget for the client-side filter walk (default: ENTRAADM_MAX_PAGES_DEFAULT). |
| signin_by_ipA | Every sign-in from one source IP: who got in from it, who was tried, and when. The follow-up to a spray_suspects or shared_ips hit: Graph can
filter sign-ins on ipAddress server-side, so this is one cheap
query, not a log walk. users summarises the IP per account
(successes, failures, first/last seen, up to 50 accounts) over every row
fetched; events lists the newest top entries that match
result ("all" by default, or "success" / "failure"), each carrying
the account name and the same AADSTS annotation as signin_logs.
capped=true means the page budget or the deadline ran out before the
window was fully read; events_truncated=true means more matching
rows were read than top returns (the users summary still counts
them). Read-only (AuditLog.Read.All application permission, or -- for azure-cli
auth -- the Reports Reader directory role). Args:
ip: The source IPv4 or IPv6 address, exactly as the sign-in log shows it.
hours: How far back to look, clamped to [1, 720] (30 days).
result: "all" (default), "success", or "failure" -- which events to list.
top: Maximum events to return, clamped to [1, 500].
max_pages: Page budget (default: ENTRAADM_MAX_PAGES_DEFAULT). |
| signin_failure_statsA | Tenant-wide sign-in failure aggregation -- the Entra ID counterpart to the RADIUS failure patrol. Time-bounded (ENTRAADM_DEADLINE, default 45 s): on a wide window or a busy day the scan
stops early and capped=true marks the counts as a lower bound; narrow hours for a
full count. Aggregates failed sign-ins across the whole tenant into four views: top
AADSTS error codes (with the same meaning annotations as
signin_logs), top failing users, top applications, and top source
IPs. spray_suspects flags any IP with failed sign-ins against 5 or
more distinct users -- Entra's smart lockout is per-account, so a
low-and-slow password spray from one IP across many accounts does not
trip it the way a brute force against one account does; this is the
observation a per-account view cannot make on its own. This mirrors the
KeyCloak-side spray detection this fleet already relies on; neither the
official Microsoft MCP Server for Enterprise nor Graph itself offers this
aggregation. When ENTRAADM_SITES_INI names the tenant's own address ranges, IPs inside
them get site (and site_name) in top_ips, and suspects inside
them move from spray_suspects to internal_spray_suspects: a NAT
egress or a room of shared workstations fails for many users every day,
and would otherwise bury an outside source. Internal suspects are kept,
not dropped, because an internal host can be compromised too. sites
reports whether the file loaded; an invalid CIDR is reported in
sites.error while the valid ranges are still used, and a file that
cannot be read leaves every IP in spray_suspects. Read-only (AuditLog.Read.All application permission, or -- for azure-cli
auth -- the Reports Reader directory role). Graph cannot filter sign-ins
on status/errorCode server-side, so this walks up to max_pages of the
full sign-in log for the window and aggregates client-side --
capped=true means the page budget ran out before the window was
fully scanned, so the counts below are a sample of the window, not a
census of it. Args:
hours: How far back to look, clamped to [1, 720] (30 days).
max_pages: Page budget (default: ENTRAADM_MAX_PAGES_DEFAULT). |
| signin_success_statsA | Tenant-wide successful sign-in aggregation by source IP -- the view that finds a breach. signin_failure_stats shows who is being attacked; this shows whether
anyone got in. The breach signature is one source IP signing in
successfully as several different accounts, most often over a legacy
protocol (clientAppUsed such as "Authenticated SMTP" or "IMAP4",
which carry no MFA). 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), with the client apps and the countries seen.
legacy_auth_users lists the accounts that succeeded over a legacy
protocol at all, with how many IPs and countries they came from.
A campus NAT, a VDI farm or a shared proxy also puts many accounts
behind one IP, so a shared IP is a lead, not a verdict: the caller
excludes its own egress ranges and reads the client apps and countries
before calling anything a breach. Graph cannot filter sign-ins on
status/errorCode server-side, so like signin_failure_stats this
walks the sign-in log for the window and aggregates client-side. The
walk covers interactive sign-ins only (Graph's default listing): every
legacy-protocol authentication is logged as interactive, so none is
missed, but non-interactive token refreshes are not counted;
capped=true means the page budget or the deadline (ENTRAADM_DEADLINE,
default 45 s) ran out first and the counts are a lower bound -- narrow
hours for a full count. Read-only (AuditLog.Read.All application permission, or -- for azure-cli
auth -- the Reports Reader directory role). Args:
hours: How far back to look, clamped to [1, 720] (30 days).
max_pages: Page budget (default: ENTRAADM_MAX_PAGES_DEFAULT).
min_distinct_users: Distinct accounts an IP needs to appear in
shared_ips (default 2, clamped to >= 2). |
| directory_auditsA | Who did what to the directory, and when -- the operator-side counterpart to signin_logs. Every admin action against a user object (block/unblock, password reset,
role assignment, attribute edits) appears here, naming the actor
(initiated_by) and the affected object(s) (target_resources).
This is the record a manual "unblock and reset" intervention -- like the
one that closed the 2026-08-21 case this server exists to shorten --
leaves behind; it is how a later triage can tell "already handled by a
human" from "still open". user, when given, matches audits where that account is either the
initiator or a target resource. Graph's directoryAudits endpoint only
supports server-side $filter on the initiator
(initiatedBy/user/userPrincipalName), not on targetResources, so
this fetches the full time window and matches both sides client-side --
a window with many unrelated admin actions can need a larger
max_pages budget than signin_logs/signin_failure_stats to
find one specific user's audits; capped=true warns when that budget
ran out before the window was fully scanned.
Read-only (AuditLog.Read.All application permission, or -- for azure-cli
auth -- the Reports Reader directory role). Entra ID retains directory
audit logs for 30 days, same as sign-in logs. Time-bounded: the scan stops after ENTRAADM_DEADLINE seconds (default 45)
and returns what it has with capped=true. Over several days the log is
dominated by device-registration noise ("Update device"), so pass
category (for example UserManagement, RoleManagement,
GroupManagement, ApplicationManagement) to have Graph filter
server-side; that keeps a multi-day window inside the budget. Args:
category: Only this Graph audit category (letters only; default: all).
user: Restrict to audits naming this userPrincipalName as actor or target (default: all).
hours: How far back to look, clamped to [1, 720] (30 days).
top: Maximum records to return, clamped to [1, 500].
max_pages: Page budget (default: ENTRAADM_MAX_PAGES_DEFAULT). |
| get_user_auth_methodsA | Registered authentication methods for one account -- is MFA actually set up? mfa_registered answers "would this account survive a password-spray
hit": True iff at least one non-password method is registered
(Authenticator app, phone, FIDO2 security key, Windows Hello, a
temporary access pass, software OATH token, or a platform
credential/passkey). password itself is excluded from that count --
every account has one, so its presence alone says nothing about MFA
coverage.
A nonexistent account is a normal answer, not a tool failure: the result
is {"found": false, "user_principal_name": upn} rather than an
error key, matching get_user's contract. Read-only (UserAuthenticationMethod.Read.All application permission).
This endpoint is app-only only: it is not exposed to delegated
(azure-cli) auth under this tenant's current role assignment, so it
degrades to a permission error under azure-cli auth even when other
tools work. Args:
upn: The account's userPrincipalName. |
| list_subscriptionsA | The Azure subscriptions this credential can see, as ARM lists them. Read-only (GET /subscriptions; needs no RBAC beyond being able to see a
subscription at all). The discovery anchor for vm_backup_status and
vm_backup_policies: both accept a subscription id or display name from
this list. An empty list with auth working means no Reader role has
been granted anywhere yet. capped marks a listing cut short by the page
budget. |
| vm_backup_statusA | Which Azure VMs are backed up by Azure Backup, and whether the last backup succeeded. Joins every VM of a subscription (Microsoft.Compute/virtualMachines)
with the Azure-VM protected items of its Recovery Services vaults, on the
VM's ARM id (case-insensitively; ARM casing differs between the two
services). Read-only; needs the Reader role on the subscription. Per VM: protected: true when a protected item exists with
protectionState Protected or IRPending (first backup not done
yet, note says so); false when there is none, or it is
ProtectionStopped / ProtectionPaused / ProtectionError (data
may be retained, backups are not running -- the state is shown);
null (unknown) when no item was found but the vault scan was
incomplete (capped, a skipped vault, an unreadable or unreached
subscription -- in the global join, any subscription's) -- a partial
scan never produces a false "unprotected".
backup_ok: true for lastBackupStatus Completed /
CompletedWithWarnings, false for Failed, null before any
backup ran or while one is running (InProgress, Warning, an
unknown value). An item whose protectionState is missing is
protected: null with a note. When two vaults hold an item for one
VM, the one still backing up wins (duplicate_items counts them).
stale: no recovery point (lastRecoveryPoint, the proof of a
successful backup) within stale_hours. The default 36 h suits daily
policies; raise it for weekly ones (policy_name is in the row).
subscription is an id or display name from list_subscriptions;
None scans every visible subscription and joins globally, so a VM
backed up to a vault in another subscription is matched. With one
subscription named, only its vaults are scanned (coverage says so).
An unknown or ambiguous name returns found: false.
orphans are protected items whose VM no longer exists (backup data
retained for a deleted VM), reported only when that VM's subscription was
listed completely. One subscription's permission error is reported on its
own entry; the others still return. When the wall-clock budget runs out,
the remaining subscriptions are listed under skipped and capped is
set.
|
| vm_backup_policiesA | Schedule and retention of the Azure VM backup policies in each Recovery Services vault. Read-only; needs the Reader role on the subscription. Per policy:
policy_type (V1 = SimpleSchedulePolicy, V2 =
SimpleSchedulePolicyV2 with an hourly / daily / weekly schedule),
schedule (frequency, run times in UTC, weekdays, or the hourly window),
retention (LongTermRetentionPolicy: daily / weekly / monthly /
yearly blocks with their day selection and duration), instant_rp_days,
timezone and protected_item_count (ARM's own count). A schedule or
retention type this server does not know is returned as
{"type": <name>, "unparsed": true}, never dropped. subscription is an id or display name from list_subscriptions
(None = every visible subscription); an unknown or ambiguous name
returns found: false. Coverage flags as in vm_backup_status:
skipped_vaults per subscription, skipped subscriptions when the
budget ran out, capped overall.
|
| m365_service_healthA | Microsoft 365 service status per service, and the open incidents and advisories. Two Graph reads (ServiceHealth.Read.All): healthOverviews (one row
per service with its current status) and the unresolved
serviceAnnouncement/issues, newest first. A sign-in failure spike reads
very differently while Entra or Exchange has an open incident, which is
why daily_brief carries this section too. Each section degrades on its
own ({"error", "missing_permission"}); summary is computed from
whatever came back. capped means either collection was cut short by
the page budget, so the counts are lower bounds. The message center
(messages) needs a different permission and is not read. |
| daily_briefA | One-call morning-patrol summary: M365 service health, sign-in failures, spray suspects, admin actions. Combines m365_service_health, signin_failure_stats and
directory_audits into one result with a compact summary on top,
matching the shape of this fleet's other daily_brief tools. A
permission failure in one section degrades only that section's
contribution to summary -- the other sections still return in full.
Azure VM backup is deliberately not a section here: its ARM fan-out is
the one scan that could exhaust the shared budget, so call
vm_backup_status separately. Runs both sections synchronously in one tool call, unlike the sibling
gwsadm-mcp's job+poll daily_brief. If this proves too slow for a
tenant's sign-in volume against the client's tool-call timeout, port
that job+poll pattern here (tracked in this repo's CLAUDE.md Roadmap). Args:
hours: How far back to look, clamped to [1, 720] (30 days).
max_pages: Page budget passed to both sections (default: ENTRAADM_MAX_PAGES_DEFAULT).
samples: Reserved for a future drill-down sample size; currently unused. |