Google Workspace Admin Security-Audit MCP

io.github.shigechikaPublished by GitHub account shigechikav0.23.1Updated Oct 27, 2026

MCP server for Google Workspace security auditing — login audit, external sharing, daily brief

The publisher's own server: audit.domains - Domain Valuation

Overview

AI-generated overview

Read-only Google Workspace security-audit server: login, account, OAuth-grant, Drive-sharing and DMARC checks across configured domains.

What you can do
  • Audit logins and accounts: Google-disabled accounts, suspicious logins, failure top-N, suspended accounts, one named user's state, admin console actions
  • Trace one account or one source IP across Workspace applications, and run a compromise sweep with Gmail forwarding/filter settings
  • Review OAuth grants, Drive external sharing, document ACL history and shared-drive membership changes
  • Check Gmail delivery: message trace by Message-ID, group posting policy, group rosters, DMARC aggregate report summaries, daily Gmail usage counts
  • Produce a daily security brief, optionally as a background job with polling, plus a service health check
When to use it
For Workspace admins and IT/security teams who want an assistant to triage helpdesk sign-in tickets, investigate suspected account compromise, or patrol external sharing and DMARC results without clicking through the admin console.
Requirements
Local Python package (pip or uvx) run over stdio. Needs a Google Cloud service account with domain-wide delegation impersonating an audit admin, plus a config INI (path via GWSADM_CONFIG) with one section per domain: service account key file, subject, customer ID. Each tool family needs its own DWD scopes, including gmail.readonly for message trace, DMARC and the sweep's Gmail section.
Before you install
The service account key file and the config INI (keep it 0600) are sensitive credentials. gmail.readonly is a broad grant allowing message content reads for impersonated users, and DMARC report output contains sender IPs and header domains. sign_out_user is state-changing and only registered when GWSADM_ENABLE_SIGN_OUT=1; it signs one account out of all sessions. Results can be capped, so absence of findings is not proof.

Installation

In SourceWeft

  1. Open Google Workspace Admin Security-Audit MCP in the dashboard and add it to a workspace.
  2. Enable the server for the chats that should use its tools.

Desktop only via STDIO. STDIO servers start a local process, so they need the SourceWeft desktop host.

Other MCP clients

Follow the launch instructions in the repository.

README

gwsadm-mcp

English | 日本語

Google Workspace security-audit MCP (Model Context Protocol) server — read-only visibility into account locks, suspicious logins, and external file sharing, built on the Admin SDK Reports API (audit activities).

Named after the admin-console viewpoint (gwsadm = Google Workspace admin), sibling of boxadm-mcp. This is not a general-purpose Workspace MCP: it surfaces risk and changes nothing, with one opt-in exception — sign_out_user, which exists only when the server is started with GWSADM_ENABLE_SIGN_OUT=1.

Features

ToolDescription
health_checkServer version, config path, and per-domain auth probe — call at session start or after a timeout
login_auditReports API login — accounts auto-disabled by Google (account_disabled_*: leaked password, hijacked, spamming), suspicious logins, failure top-N
gmail_usage_reportReports API customerUsageReports — daily Gmail send/receive counts per domain, one date at a time, ending yesterday (the API's own UTC-8:00/PST date anchor). Requires the separate admin.reports.usage.readonly DWD scope (see Auth model below) — a DIFFERENT grant from admin.reports.audit.readonly even though both are Reports API
suspended_accountsDirectory API — current snapshot of suspended accounts (isSuspended=true); cross-reference against a downstream IdP (e.g. KeyCloak) to find suspended-but-still-enabled accounts
get_userDirectory API users().get — one named account's current state: suspended (with reason and time), archived, last_login, 2SV enrolled/enforced, org unit, creation time, pending password change. The "why can't this person sign in" lookup: one request, no pagination, for an address you already know — unlike suspended_accounts, which lists only accounts that ARE suspended, so it can never confirm that a given address is not suspended (and once that list exceeds its page cap, absence stops being evidence either way). Needs no scope beyond the one suspended_accounts already uses
user_oauth_tokensDirectory API tokens().list — third-party OAuth app grants for one user; a compromise vector login_audit is blind to, since a previously-granted token needs no fresh login. Domain resolved from the username's suffix, with an optional domain override for alias/secondary-domain addresses
sign_out_userState-changing, opt-in. Directory API users().signOut — signs one account out of every web and device session and resets its sign-in cookies, after confirming the account exists with users().get. Registered only when the server starts with GWSADM_ENABLE_SIGN_OUT=1; put an approval step in front of it. Does not revoke OAuth grants or app passwords, remove an enrolled second factor or change the password (listed in its not_covered). Needs two grants: admin.directory.user.readonly for the account check (as get_user) and admin.directory.user.security for the sign-out itself (as user_oauth_tokens); the impersonated admin must also be allowed to manage user security. Answers signed_out true (Google confirmed), false (nothing changed) or null (outcome unknown: sent, no answer in time) with the same keys every time, and ends within GWSADM_SIGN_OUT_DEADLINE_SECONDS (default 45). The address's own domain must be configured; domain, if given, must match it.
user_activityReports API activities().list with userKey=<username> — everything one account did in a window, merged across applications (login, token, user_accounts, drive, groups, calendar, chat, meet, saml by default) newest first. flags surfaces the events that change the account's security posture regardless of volume — 2SV enrolled/disabled, recovery email/phone changed, password changed, outbound forwarding, Google's risky_sensitive_action_* / suspicious_login verdicts, every OAuth authorize with client and scopes — because a second factor the attacker enrolled survives a password reset; ips lists the distinct source addresses with first/last seen. Google's own account_disabled_* / suspicious_login verdicts are not served under the account's userKey (their actor is Google), so they are fetched the way login_audit does and reported under the synthetic application login_verdicts. The post-compromise question login_audit cannot answer: it sees only the events Google itself raised, domain-wide
ip_activityReports API activities().list with actorIpAddress=<ip> — every account one source address acted as, per domain and application, with the same flags. The follow-through after user_activity names an attacker's address: did the same source reach other accounts? A spray shows up as login_failure counts before it ever succeeds
compromise_sweepThe Google-side follow-through after one account is confirmed compromised, in one call: activity (user_activity, flags first), state / grants (get_user; OAuth grants with mail / Drive / contacts / admin scopes called out as risky), ips (ip_activity across every domain for the attacker's addresses — given, or taken from the account's own flags — with the account itself excluded), and gmail: the settings an attacker leaves behind to keep reading mail (auto-forwarding, forwarding addresses, filters that forward / delete / archive, delegates, send-as and reply-to) plus what the mailbox sent in the window as counts and recipient domains — never a subject or body. gmail.kind says whether it could be read at all: ok / partial (one settings section or the sent summary failed; the rest stands) / api_disabled (the Gmail API is off in the service account's own Cloud project) / no_grant / error — a failure is reported, never read as "nothing found". highlights lists the facts to act on, checklist what remains to do by hand. Shares gmail_message_trace's gmail.readonly DWD scope (every settings read, delegates.list included, is served by it — verified live)
drive_external_sharingReports API drive — ACL grants to external addresses or domains (revocations reported separately) and visibility transitions into link/public exposure
drive_doc_activityReports API drive with a server-side doc_id filter — one document's owner, ACL changes, and lifecycle events. Triage companion to drive_external_sharing: the owner (an individual vs. a shared drive's name) disambiguates the shared-drive false-positive class, where files created inside a shared drive propagate member ACLs and read as bulk external sharing
shared_drive_membership_changesReports API drive (shared_drive_membership_change) — who added/removed/re-roled shared-drive members and when, with external classification of the affected member and a client-side drive-name filter
admin_actionsReports API admin — what administrators (people, or a sync or API client acting as one) did in the Admin console: to one user or group (server-side USER_EMAIL / GROUP_EMAIL filters on the target's domain) or tenant-wide. Answers "who suspended this account / reset its password / took it out of a group?", which user_activity cannot: an admin event's actor is the administrator, not the account acted upon
workspace_statusGoogle Workspace Status Dashboard (public feed, no credential) — open incidents and those that ended within the window, optionally for one product (Gmail, Drive, ...). Answers "is it Google?" before a tenant-side investigation
gmail_message_traceGmail API — did a known Message-ID reach specific mailboxes, and where (inbox/spam/trash/archived)? For each recipient it impersonates that user via DWD and searches their own mailbox. Requires the separate gmail.readonly DWD scope (see Auth model below); a domain missing that grant reports a per-recipient error, never a false "not found"
dmarc_rua_summaryGmail API — DMARC aggregate (RUA) report pass/fail summary and top reject-candidate source IPs, per domain. Impersonates the domain's configured dmarc_rua_mailbox (a real user; default postmaster@<domain>), searches it for mail addressed to dmarc_rua_recipient (the published rua= address, e.g. postmaster+rua@; default: the mailbox) and reads the compressed report attachments those messages carry. Shares gmail_message_trace's gmail.readonly DWD scope, but unlike that tool this one DOES read attachment content (the report XML), not just metadata — see Auth model below
group_delivery_policyGroups Settings API — a Google Group's own posting/delivery policy (who_can_post, allow_external_members, moderation levels). A group's access control sits in front of Gmail delivery: a domain-only posting policy silently drops an external sender's mail before it generates any Gmail delivery event at all, indistinguishable from a delivery failure without reading the policy directly. Requires the separate apps.groups.settings DWD scope (see Auth model below)
list_group_membersDirectory API — a Google Group's basic metadata and member roster, resolved directly rather than inferred from who happened to receive one particular message. Requires the separate admin.directory.group.readonly and admin.directory.group.member.readonly DWD scopes (see Auth model below)
daily_briefOne-call summary across all configured domains, including account-protection changes (2-Step Verification, passkeys, Advanced Protection, password, recovery address/phone, mail forwarding out of the domain) flagged when they come from outside your home countries (GWSADM_IP2ASN, GWSADM_HOME_COUNTRIES), from an origin that cannot be placed, or follow a suspicious login
daily_brief_start / daily_brief_resultSame as daily_brief, run in the background: start returns a job_id immediately, then poll result(job_id) until done. Use on large tenants where the synchronous call risks the client's ~60s tool-call timeout

Planned: dlp_events (Reports rules; requires a Workspace edition with DLP), token_events.

Auth model

Service account with domain-wide delegation (DWD) impersonating an audit-capable admin. Fully non-interactive — no browser, no token refresh rotation — so the server runs unattended (cron, MCP gateway, CI).

Grant all of the following DWD scopes on the same service-account client ID up front, in one setup pass. Adding them one at a time as each tool gets built is how a scope goes missing until the one tool that needed it starts degrading — one place, one pass, avoids the trap:

ScopeNeeded byMissing it
https://www.googleapis.com/auth/admin.reports.audit.readonlylogin_audit, user_activity, ip_activity, admin_actions, drive_external_sharing, drive_doc_activity, shared_drive_membership_changes, daily_brief*those tools degrade to a per-domain error
https://www.googleapis.com/auth/admin.directory.user.readonlysuspended_accounts, get_user, sign_out_user (opt-in; its account check)those tools degrade to an error (per-domain for suspended_accounts); everything else keeps working
https://www.googleapis.com/auth/admin.directory.user.securityuser_oauth_tokens, sign_out_user (opt-in)those tools degrade to a per-domain error; everything else keeps working

health_check needs no scope at all to respond: it is the tool to call when a grant might be missing — it probes each domain and reports the failing auth in a structured per-domain result instead of failing itself.

gmail_usage_report needs its own separate scope too, despite living under the same Admin SDK Reports API as the base pass above — the "Usage report" family (customerUsageReports) and the "Audit" activity stream (activities().list, everything else in the base pass) are gated by two different scopes, and having one does not imply the other:

ScopeNeeded byMissing it
https://www.googleapis.com/auth/admin.reports.usage.readonlygmail_usage_reportthat tool degrades to a per-domain error; everything else keeps working

gmail_message_trace and dmarc_rua_summary need one more scope, granted as a separate step — it is intentionally not bundled into the pass above:

ScopeNeeded byMissing it
https://www.googleapis.com/auth/gmail.readonlygmail_message_trace, dmarc_rua_summary, compromise_sweep (its gmail section)those tools report a per-recipient/per-domain error; everything else keeps working

This is a materially broader grant than the three above: it allows reading message content for any user the service account impersonates, not just metadata. gmail_message_trace only ever requests format="metadata" — it never reads a message body — but dmarc_rua_summary DOES read content: it fetches the compressed DMARC report attachment each RUA message carries (format="full" plus attachments().get()) and parses it. Both stay within what the grant allows either way, but only gmail_message_trace stays inside the narrower "metadata only" habit; the narrower gmail.metadata scope was considered and rejected for both tools because it does not support the q= search parameter the rfc822msgid:/RUA-mailbox lookups need. Grant it on the same service-account client ID as the other scopes (Admin console → Security → API controls → Domain-wide delegation → find the existing client ID → add this scope to its list), and weigh that broader exposure against how much you actually need message-trace/DMARC reporting before turning it on for a given domain.

group_delivery_policy and list_group_members each need their own separate scope too — three more grants beyond the base pass, none bundled with each other or with gmail.readonly above:

ScopeNeeded byMissing it
https://www.googleapis.com/auth/apps.groups.settingsgroup_delivery_policythat tool degrades to an error; everything else keeps working
https://www.googleapis.com/auth/admin.directory.group.readonlylist_group_members (group metadata half)that half reports its own error; the member roster half still works independently if its own scope below is granted
https://www.googleapis.com/auth/admin.directory.group.member.readonlylist_group_members (member roster half)same, independent of the metadata half above — the two calls never gate each other

The Groups Settings API is a distinct product from the Directory API, hence the separate scope; it has no readonly-only variant, but this server only ever calls groups().get(), never a mutating method.

suspended_accounts, get_user and user_oauth_tokens all operate per configured domain (Directory domain=/userKey=), unlike the customer-wide Reports tools — so every domain you want covered (e.g. a separate student domain) needs its own [domain.*] config section. Note the failure modes differ: suspended_accounts silently omits an unconfigured domain from its result, while get_user and user_oauth_tokens fail loudly with an unknown-domain error (both take a domain override for an alias/secondary address whose suffix has no section of its own).

Setup

bash
# uvuv pip install gwsadm-mcp
# pippip install gwsadm-mcp

Or from source:

bash
git clone https://github.com/shigechika/gwsadm-mcp.gitcd gwsadm-mcp
# uvuv sync
# pippip install -e .

Configuration

Point GWSADM_CONFIG at an INI file (default ~/.config/gwsadm-mcp/config.ini, keep it 0600):

ini
[gwsadm]# optional; defaults to all [domain.*] section namesinternal_domains = example.edu, mail.example.edu
[domain.example.edu]service_account_file = /path/to/service-account.jsonsubject = [email protected]customer_id = C0xxxxxxxdmarc_rua_mailbox = [email protected]   # optional, default: postmaster@<domain>; "none" opts outdmarc_rua_recipient = [email protected]   # optional, default: same as dmarc_rua_mailbox

One [domain.*] section per audited Workspace domain. internal_domains is the allowlist used to classify sharing targets as internal vs external. dmarc_rua_mailbox is the real user dmarc_rua_summary impersonates to read DMARC aggregate reports — domain-wide delegation can only act as an actual user, never as a group or alias. dmarc_rua_recipient is the address the reports are sent to (the rua=mailto: value published in the domain's _dmarc record) and is used only to narrow the Gmail search (to:<recipient>); it defaults to the mailbox. Set it when the published address is a Gmail plus-subaddress such as postmaster+rua@ (searching on it also keeps ruf= failure reports sent to postmaster+ruf@ out of the aggregate parse) or a group that fans out to the impersonated inbox. dmarc_rua_mailbox = none opts a domain out of DMARC reading — e.g. when its rua= points at another domain's mailbox that a different [domain.*] section already reads; reports are grouped by the policy domain each report names, so they still appear under that other section.

Environment variables (all optional except where noted):

VariableDefaultMeaning
GWSADM_CONFIG~/.config/gwsadm-mcp/config.inithe INI file above
GWSADM_MAX_WORKERS8concurrent Reports API fetches in the fan-out (clamped to 1–32; a non-integer falls back to 8). Lower it if a tenant throttles, raise it if a large tenant's scan is too slow
GWSADM_SITES_INI—your own address ranges, to name where an account-protection change came from (see the setup docs)
GWSADM_IP2ASN / GWSADM_HOME_COUNTRIES—iptoasn table and home countries, to flag changes from abroad (see the setup docs)
GWSADM_ENABLE_SIGN_OUTunset1 registers the state-changing sign_out_user
GWSADM_SIGN_OUT_DEADLINE_SECONDS45time budget of one sign_out_user call (clamped to 10–300)

Usage

Claude Code (plugin)

This repository doubles as a single-plugin marketplace, so Claude Code can install the server for you:

/plugin marketplace add shigechika/gwsadm-mcp/plugin install gwsadm-mcp@gwsadm-mcp

The plugin launches uvx gwsadm-mcp and reads GWSADM_CONFIG (falls back to ~/.config/gwsadm-mcp/config.ini), the same variable described in Configuration. /plugin install only wires up the server process — it cannot create the config INI or the Google Cloud service-account JSON key(s) it points at; both must already exist on the machine running the plugin before any tool call will succeed.

uvx must be on the PATH of the process that runs Claude Code — a login shell usually has it, but a GUI-launched app may not; install uv system-wide if the plugin fails to start.

Claude Code (manual)

Add to .mcp.json (no env needed when the config lives at the default path; add "env": { "GWSADM_CONFIG": "..." } only for a non-default location):

json
{  "mcpServers": {    "gwsadm-mcp": {      "type": "stdio",      "command": "gwsadm-mcp"    }  }}

Claude Desktop

Add the same entry to claude_desktop_config.json.

Codex

bash
codex mcp add gwsadm-mcp -- gwsadm-mcp

Add --env GWSADM_CONFIG=... (before --) only if the config INI is not at the default path (~/.config/gwsadm-mcp/config.ini).

Gemini CLI

Add to ~/.gemini/settings.json (or a project's .gemini/settings.json):

json
{  "mcpServers": {    "gwsadm-mcp": {      "command": "gwsadm-mcp"    }  }}

As with Codex, add "env": { "GWSADM_CONFIG": "..." } only for a non-default config location. To expose only some tools, list them in includeTools.

Any other MCP client that launches stdio servers works the same way: give it gwsadm-mcp as the command.

Direct Execution

bash
gwsadm-mcp

CLI Options

bash
gwsadm-mcp --version   # Print version and exitgwsadm-mcp --check     # Config + auth + API smoke for every domain, then exitgwsadm-mcp             # Start MCP server (STDIO, default)

--check exit codes: 0 success, non-zero on config or auth failure.

Archiving DMARC reports (batch)

dmarc_rua_summary aggregates a rolling window for a patrol. To keep a day-by-day history, a scheduled job can dump the reports themselves:

bash
gwsadm-mcp dmarc-reports --domain example.edu --since 2026-09-20 --until 2026-09-23 > reports.json
  • Reads the same RUA mailbox as dmarc_rua_summary (same gmail.readonly scope) for messages that arrived in [--since, --until) (UTC days, Gmail after: / before:), every attachment and every ZIP entry (up to 20 entries and 50 MB decompressed per attachment; anything past that, or an encrypted / corrupt entry, is counted in non_report_attachments).
  • Prints one JSON document to stdout: each report whole — org_name, report_id, begin / end (epoch seconds of the reported period), the published policy (p, sp, pct, adkim, aspf) and its records with reason and auth_results. Nothing is aggregated or deduplicated: some reporters split one report across several messages under the same report_id, so deduplicate on content, not on the id.
  • fetch_complete is true only when nothing was capped and no message, attachment or record was skipped (message_errors, non_report_attachments, dropped_records).
  • Exit 2 for a bad argument or configuration, 1 for an API or auth failure (e.g. the scope was revoked); nothing is written to stdout in either case.
  • The output contains sender IPs and header domains. Keep it out of anything published.

Notes

  • Every result section reports capped: true when a window exceeded the page budget, or when a probe's fetch errored outright (see event_errors) — partial coverage is never presented as "no findings". The drive scan also reports capped_events (which eventNames were cut short). Narrow hours or raise max_pages for full coverage — on a large tenant, term-time weekdays can produce thousands of change_user_access events/day.
  • Google's visibility=shared_externally is relative to the file owner's domain, so with multiple internal_domains a cross-internal-domain grant (e.g. student domain → staff domain) carries it too. External-ness is therefore judged against internal_domains using the grant's target: target_user for named grants, target_domain for domain-scoped grants (e.g. "anyone at partner.edu"; the literal domain "all" means "anyone with the link" and is judged by visibility instead). risky_visibility_events counts only transitions into people_with_link / public_on_the_web (excluding a narrowing from public down to link-only). untargeted_external_transitions is a residual bucket for transitions into shared_externally with no target address or domain to classify — it is not a cross-check for grants missed elsewhere, since domain-scoped grants are already counted above. external_samples / exposure_samples / untargeted_samples hold examples of each.
  • Drive events are queried one audit-relevant eventName at a time, so the page budget is not consumed by view/edit noise; an event name rejected by the API degrades into event_errors instead of failing the tool. change_document_visibility and change_document_access_scope report the same transition as simultaneous sibling events on this API — only the latter drives classification (the former is fetched for its acl_events count only), so a domain-scoped grant or a link/public exposure is never double-counted across the two. This also means the former can no longer compensate if the latter's own fetch fails: a change_document_access_scope entry in event_errors sets capped: true for that domain, and its classification counts for the window are a lower bound even though change_document_visibility (and thus acl_events) may show data.
  • A failure in one domain degrades only that domain's section ({"error": ...}).
  • gmail_message_trace sets ambiguous: true (with match_count) on a recipient whose mailbox has more than one message under the same Message-ID (mailing-list copy plus a direct CC, a quarantine-release duplicate, …) — the rest of that recipient's fields describe only the first match, not a combined answer. match_count_capped is set alongside it when the mailbox has enough matches that match_count is a lower bound rather than exact (the search does not paginate).
  • get_user distinguishes "this address names no account" from "the lookup failed": a plain HTTP 404 answers found: false with no state fields, which is a diagnostic result — a typo'd or deleted address — and never an error. A missing DWD scope or a transient failure answers {"error": ...} with no found key instead, so the two can never be confused in either direction. Fields Google omits stay null rather than being coerced: a missing suspended must not read as "the account is fine".
  • group_delivery_policy normalizes the Groups Settings API's "true"/"false" string fields (a quirk of that API, not JSON booleans) into real booleans in its output; a field absent from Google's response stays null, never coerced to false. list_group_members runs its group-metadata and member-roster lookups independently — a tenant with only one of the two DWD scopes still gets that one section, the other reported as {"error": ...} in its place. It reports capped: true both when the member roster exceeded its page budget (default 20 pages × 200/page) and when the member lookup failed outright (see members_error) — either way the roster is not the full one, and an empty members list must never be read as a confirmed-empty group when capped is true. Both group tools distinguish "this address is not a group" (a plain HTTP 404, verified against production for all three underlying API calls) from a real failure: group_delivery_policy sets found: false; list_group_members sets it too, when either both independent lookups agree with no error on either side, OR one CONFIRMS not-found while the other independently failed (that failure is then attached as group_lookup_error / members_lookup_error rather than hidden) — a confirmed non-existence outweighs an unrelated error on the other scope. Only a genuine mixed state (one side not-found, the other actually finding data) falls through to the normal per-section shape instead.
  • Read-only by design, with one opt-in exception. The only read calls issued are activities().list() and customerUsageReports().get() (Reports API); users().list(), users().get(), tokens().list(), groups().get(), members().list() (Directory API); groups().get() (Groups Settings API); and in the Gmail API messages().list() / messages().get(format="metadata") for gmail_message_trace and compromise_sweep (the latter with the To/Cc/Bcc/Date headers only), messages().list() / messages().get(format="full") / messages().attachments().get() for dmarc_rua_summary (which does read the report attachments, not just metadata), the settings reads users().settings().getAutoForwarding() / forwardingAddresses().list() / filters().list() / delegates().list() / sendAs().list() for compromise_sweep, and users().getProfile() for the health_check Gmail probe. The only write is users().signOut() (Directory API), issued solely by sign_out_user — which exists only when the server starts with GWSADM_ENABLE_SIGN_OUT=1. workspace_status also GETs Google's public status feed without a credential.
  • Output contains account addresses (that is the point of an audit tool): restrict access to authorized security staff. gmail_message_trace also returns a message snippet and headers (From/To/Cc/Subject/Date) for a matched message — treat its output with the same care as the mailbox content it is drawn from.

Development

bash
git clone https://github.com/shigechika/gwsadm-mcp.gitcd gwsadm-mcp
# uvuv sync --devuv run pytest -vuv run ruff check .
# pippython3 -m venv .venv.venv/bin/pip install -e . && .venv/bin/pip install pytest ruff.venv/bin/pytest -v.venv/bin/ruff check .

Live smoke test

The unit suite never talks to Google, which is what makes it fast — and also what makes it blind to a tool that has stopped returning real data. scripts/smoke_test.py runs every registered tool against the configured tenant and fails on empty, malformed or error answers:

bash
# uses the same config file as the server (GWSADM_CONFIG)uv run python scripts/smoke_test.pyuv run python scripts/smoke_test.py --only oauth --traceback
  • Read-only. Every tool exercised here reads an audit log or a directory snapshot; nothing in Workspace is changed (sign_out_user is skipped). daily_brief_start creates a job inside the process, which expires on its own.
  • No payloads in the report. Tool names, statuses and row counts only; server-authored error text is redacted too, since these tools deal in account addresses and document titles throughout.
  • Bounded. Every bounding parameter a tool offers is passed explicitly — the defaults (5 pages, 180 days, 200 events) are sized for a human asking once, and are enforced by a test that finds them from the source.
  • Nothing tenant-specific in the specs. The account and the document the per-user and per-document tools need are discovered at run time, and skipped when the tenant has none to offer. Two tests keep it that way: one refuses those parameters as literals, the other bans anything address-shaped anywhere in the file, because this repository is public.
  • An empty answer passes: no external sharing and no locked accounts is the desired state. What is asserted instead is the envelope — and, where the answer is keyed by domain, that the domain map is not empty, since a config resolving to zero domains would otherwise report every tool as working while auditing nothing.
  • CI enforces the cheap half: a tool registered without a probe spec fails the build (tests/test_smoke_probes.py), so adding a tool forces the question "how would we know it works?".
  • scripts/smoke_harness.py is the engine and holds no Workspace knowledge: it is kept identical across the servers that share it, so fix engine bugs once and sync the file rather than patching this copy.

Releasing

Releases are automated with release-please. Merging Conventional Commits (feat:, fix:, …) to main keeps a release PR open with the next version and changelog. Merging that PR tags vX.Y.Z and publishes a GitHub Release, whose release: published event triggers the release workflow to build and publish to PyPI and the MCP Registry. release-please owns the version in gwsadm_mcp/__init__.py and server.json (do not bump them by hand).

[!IMPORTANT] The release-please workflow should be given a repository secret RELEASE_PLEASE_TOKEN (a PAT with contents: write + pull-requests: write). The default GITHUB_TOKEN cannot create the Release that triggers the downstream release workflow (GitHub blocks workflow runs triggered by GITHUB_TOKEN), so without the PAT nothing gets published. The workflow falls back to GITHUB_TOKEN when the secret is unset so PR CI keeps working on forks.

License

MIT

Source: README.md at commit 17749de

Tools

21

health_check

Report service status, version, and per-domain auth/API reachability. Always returns the same keys: status (healthy / degraded / error), service, version, config, and domains (per-domain auth result plus ``gmail``: ok / api_disabled / no_grant / error, with ``gmail_detail`` on an unexpected error -- whether the per-user Gmail tools can work there; it does not affect ``status``), ``sites`` (the optional ``GWSADM_SITES_INI`` table: configured / ranges / error) and ``geo`` (the optional ``GWSADM_IP2ASN`` dataset: configured / home_countries / age_days / error / warning); both are informational and do not affect ``status``. Lightweight: one 1-item login query and one ``users.getProfile`` per domain.

Takes no parameters.

login_audit

Audit the login log: Google-auto-disabled accounts, suspicious logins, failure top-N. Answers "did Google itself decide something was wrong with an account here, and with which account?" — the question a "my mail suddenly stopped working" ticket usually turns out to be. ``account_disabled_*`` is Google reporting that it locked an account (leaked password, hijacking, spamming); ``suspicious_login`` and ``gov_attack_warning`` are warnings without a lock. In both sections ``user`` is the account the event is ABOUT, not an actor who did something: Google raised these itself, so the actor is Google and the account is read from the event's ``affected_email_address``. Treat a hit as evidence about that account, and reach for ``get_user`` next for its current state. An ``account_disabled_spamming`` entry means Google observed outbound spam, which is a compromise finding, not a delivery problem — the account was almost certainly being used by someone else. The IdP upstream is a separate system and is NOT disabled by this: an account locked here can still authenticate there until it is disabled there too. Combine with a Directory suspended-users snapshot (Phase 2) for current state. Each section carries ``capped`` (window not fully scanned) — treat counts as lower bounds then.

Parameterstophoursdomaininclude_failures

gmail_usage_report

Daily Gmail send/receive counts per domain (Admin SDK customerUsageReports). Answers "how much Gmail traffic did this domain send/receive on day X" -- a customer-level daily counter, NOT a per-user or per-message breakdown (the Reports API has no such thing; ``login_audit``'s activity stream is the per-event alternative for that, at the cost of no volume total). Each day's fetch is independent: a domain missing the DWD scope reports one whole-domain ``error`` (there is no point retrying `days` times for an identical scope failure), but a single date's fetch failing for some other reason (Google's processing for that day not finished yet is a known lag on this API family; a transient error) only skips that one date, recorded in ``date_errors`` -- the rest of the window still comes back. The per-domain result also carries ``capped`` -- true if ANY date's fetch hit the (rare, since a single day's customer report is normally one record) pagination limit -- so a truncated day's counters are never mistaken for the complete picture. Requires the ``admin.reports.usage.readonly`` DWD scope, granted PER SERVICE ACCOUNT CLIENT ID in the Admin console (Security > API controls > Domain-wide delegation) -- a DIFFERENT scope from ``admin.reports.audit.readonly`` (the one ``login_audit``, ``drive_external_sharing``, ``drive_doc_activity``, ``shared_drive_membership_changes`` and ``daily_brief`` use), even though both live under the same Admin SDK Reports API. Having one does not imply the other; grant this one separately. Read-only: only ``customerUsageReports().get()`` is issued, restricted to the two Gmail counters this tool reports via the API's own ``parameters`` filter (:data:`GMAIL_USAGE_PARAMETERS`) rather than pulling every application's counters. Args: days: How many days back to report, ending YESTERDAY (not today -- the current day's data i

Parametersdaysdomain

suspended_accounts

Snapshot of currently suspended Google Workspace accounts, per domain. A suspended-but-still-provisioned account is a common attack surface: an account disabled in Google may remain enabled in a downstream IdP (e.g. KeyCloak), where a password-spray attacker can still authenticate through it. Cross-reference this list against the IdP to find and disable such gaps. Unlike ``login_audit`` (which reports the *event* of Google disabling an account within a time window), this is current *state* — every account suspended right now, regardless of when. Read-only (Directory API ``users().list`` with ``query=isSuspended=true``). Requires the ``admin.directory.user.readonly`` DWD scope; a domain missing that grant degrades to ``{"error": ...}`` for that domain only. ``capped`` is set when ``max_pages`` was hit before the listing was exhausted. Coverage is per configured domain (Directory ``domain=`` filter), unlike the customer-wide Reports tools — every domain you want covered (e.g. a separate student domain) must have its own ``[domain.*]`` config section, or its suspended accounts are not listed. Args: domain: Restrict to one configured domain (default: all). max_pages: Page cap (500 accounts/page); ``capped=true`` means more exist.

Parametersdomainmax_pages

get_user

Look up ONE named account's current state — the "why can't this person sign in" tool. Answers a helpdesk ticket that already names the exact address: is the account suspended (and for what reason, since when), archived, enrolled in or enforced into 2-step verification, when did it last log in, which org unit is it in, is a password change pending. One Directory API request, no pagination. Use this — not ``suspended_accounts`` — whenever the address is known. That tool lists only accounts that ARE suspended, so it can never confirm that a given address is *not* suspended, and once that list exceeds its page cap absence stops being evidence either way — after spending far more API calls than this. ``suspended_accounts`` is for the domain-wide sweep it is actually named for. This directory is downstream of the identity provider, not the master. Read the answer as "what Google Workspace currently believes about this account" and compare it against the IdP's own record, which is authoritative for who the account is. A disagreement is usually drift on this side rather than a mistyped address — an account the IdP still authenticates can be suspended or archived here, and an address the IdP does not assert at all will simply come back ``found: false``. An address that names no account returns ``found: false`` with no state fields. That is a normal, expected answer — a typo'd or long-deleted address — and is itself the diagnostic result, NOT a failure. A missing DWD scope, a rejected credential or a transient API failure is reported as ``{"error": ...}`` instead. The two are deliberately distinct: never read ``found: false`` as "the lookup did not work", and never read an ``error`` as evidence about whether the account exists. Read-only (Directory API ``users().get``; this tool never changes the account). Requires the ``admin.directory.user.readonly`` DWD scope —

Parametersdomainusernamerequired

user_oauth_tokens

List third-party OAuth apps one user has granted account access to. Account-compromise triage tool for the case ``login_audit`` and ``suspended_accounts`` are both blind to: a malicious app used a previously-granted OAuth token to read/delete mail or Drive files without ever generating a fresh login event. Check each entry's ``scopes`` for Gmail/Drive access on an unrecognized ``client_id``/``display_text`` — Google's own apps (e.g. iOS/Android account sync) show up too and are normal noise. Read-only (Directory API ``tokens().list``; never ``tokens().delete()``). Requires the ``admin.directory.user.security`` DWD scope — distinct from ``admin.directory.user.readonly`` used by ``suspended_accounts``; a domain missing that grant returns ``{"error": ...}``. No pagination: the API returns a user's full grant list in one response. Args: username: Exact user email, passed through as the Directory API ``userKey`` (primary or alias address both work on Google's side). domain: Configured ``[domain.*]`` section to route the lookup through. Default: resolved from the username's suffix. Set it explicitly when the address uses an alias/secondary domain that has no config section of its own (common when copying addresses from mail headers or IdP logs).

Parametersdomainusernamerequired

user_activity

Everything ONE account did across Workspace in a window, newest first. The first question after an upstream identity provider reports an account as compromised: did the attacker get into Google, and what did they do there? ``login_audit`` cannot answer it — it is a domain-wide view of the events Google itself raised, so it shows neither one named account's logins nor the logins Google did not flag. This tool asks the Reports API for that one account (``userKey=<username>``, server-side) across several applications at once and merges the answers. Read ``flags`` first. It holds the events that change the account's security posture or mark it as under attack, regardless of how much ordinary activity surrounds them: a second factor enrolled or disabled (``2sv_enroll`` survives a password reset — it is the attacker's way back in), recovery email/phone changes, a password change, outbound mail forwarding to another domain, Google's own ``risky_sensitive_action_*`` verdicts, a login Google marked ``is_suspicious``, and every OAuth ``authorize`` (client and scopes — note that a service account impersonating the user through domain-wide delegation, this server's own Gmail tools included, appears here too, as an ``authorize`` whose ``client_id`` is the service account's numeric id). Google's ``account_disabled_*`` / ``suspicious_login`` / ``gov_attack_warning`` verdicts are flags too, but they are NOT served under the account's own ``userKey`` (their actor is Google), so they are fetched separately the way ``login_audit`` does and reported under the synthetic application ``login_verdicts`` — its ``event_errors`` names any verdict probe that failed. ``ips`` lists the distinct source addresses with first/last seen and which applications each touched — the input to ``ip_activity`` for the "did the same source reach other accounts" question. ``timeline`` is the merged eve

Parametershoursdomainusernamerequiredmax_pagesmax_eventsapplications

ip_activity

Every account ONE source address acted as, across applications and domains. The follow-through after ``user_activity`` names an attacker's address: did the same source get into other accounts? The Reports API filters on ``actorIpAddress`` server-side, so this is one probe per (domain × application) over the matching events only, not a scan of the whole tenant's log — and every domain's probes run in one pool, so a slow domain does not delay another's answer. Per domain it returns the acting ``accounts`` (count, first/last seen, which applications) ordered by most recent, the posture ``flags`` the address triggered (same definition as ``user_activity``; Google's own verdicts are included here when they carry the address, since ``userKey=all`` sees them), and per-application accounting. Failed logins from the address appear under ``applications.login.events`` as ``login_failure`` — a spray from this address shows there before it ever succeeds. Coverage reads as in ``user_activity``: ``window.start``, per-application ``capped`` / ``error`` (and the union ``capped``, a failed application included), and a per-domain ``error`` when that domain's credential is rejected (the other domains still answer). A quiet answer for a current-day window can be data lag rather than absence. Read-only (Reports API ``activities().list`` only, the ``admin.reports.audit.readonly`` DWD scope). Args: ip: The source address, IPv4 or IPv6 literal (no zone id). Validated before use. hours: Window length ending now. applications: Comma-separated application names; default as in ``user_activity``. max_pages: Page budget per (domain × application). top: Accounts returned per domain, most recently seen first; ``accounts_total`` says how many there were. domain: Restrict to one configured ``[domain.*]`` section. Default:

Parametersiprequiredtophoursdomainmax_pagesapplications

compromise_sweep

The Google-side follow-through after ONE account is confirmed compromised, in one call. Four questions, each answered from its own source and reported in its own section so that a failure in one never hides the others: - ``activity``: what the account did (``user_activity`` for the window, flags first -- a ``2sv_enroll`` from the attacker is the item that survives a password reset). A Reports failure stays in this section as ``error``; the others are still attempted with their own grants. - ``state`` / ``grants``: what the account is now (``get_user``: suspended, 2SV enrolled, last login) and which OAuth grants can read or act on content (``grants.risky``: mail / Drive / contacts / admin scopes; sign-in-only grants are listed but not called out). - ``ips``: whether the same sources reached other accounts (``ip_activity`` across every configured domain for the addresses in ``ips``, or, when none are given, for the addresses on the account's own flags -- which include the attacker's, and also the owner's own when they authorized an app or re-authenticated -- capped at SWEEP_MAX_IPS, newest first). The swept account itself is excluded from ``other_accounts``; a shared egress address (a campus NAT, a gateway host that impersonates accounts) legitimately shows other accounts, so read ``other_accounts`` against where the address sits. - ``gmail``: the settings an attacker leaves behind to keep reading mail (auto-forwarding, forwarding addresses, filters that forward / delete / archive, delegates, send-as aliases and reply-to) plus what the mailbox sent in the window as counts and recipient domains -- never a subject or body. Its ``kind`` says whether it could be read at all: ``ok``, ``partial`` (a settings section or the sent summary failed -- ``section_errors`` / ``sent.error`` say which; the rest stands), ``api_disabled`

Parametersipshoursdomainmax_sentusernamerequiredmax_pages

gmail_message_trace

Check whether a message (by RFC 822 Message-ID) reached specific users' mailboxes. Answers "who got this email and who didn't" for a KNOWN Message-ID and a KNOWN candidate recipient list — there is no Workspace API to search across every user for one message, so the caller supplies who to check (a mailing-list roster, or simply the people who reported a problem). For each recipient this impersonates that exact user via domain-wide delegation and searches their own mailbox (including Spam and Trash) for the Message-ID. Requires the ``gmail.readonly`` DWD scope — granted PER SERVICE ACCOUNT CLIENT ID in the Admin console (Security > API controls > Domain-wide delegation), separately from the ``admin.directory.*`` / ``admin.reports.*`` scopes the rest of this server uses, and NOT on by default. A domain missing that grant reports a per-recipient ``error`` rather than a silent "not found" — the two must never be confused, since "not found" here can also legitimately mean the message was delivered and later deleted by the user, or never delivered at all; this tool cannot tell those apart, only "a match currently exists in this mailbox" from "it doesn't". Read-only: only ``messages().list`` and ``messages().get`` (metadata only, never the message body) are issued against each impersonated mailbox — see ``DomainClient.find_message_by_id``. A per-recipient result sets ``ambiguous: true`` (with ``match_count``) when more than one message in that mailbox shares the Message-ID (e.g. a mailing-list copy plus a direct CC) — the other fields describe only the first match in that case, not a combined answer. ``match_count_capped`` is set alongside it when the mailbox has enough matches that ``match_count`` itself is a lower bound, not exact. Args: message_id: The RFC 822 Message-ID to search for, with or without angle brackets. Must be shaped like an

Parametersdomainmessage_idrequiredrecipientsrequired

dmarc_rua_summary

Summarize DMARC aggregate (RUA) reports: pass/fail per domain, top reject-candidate IPs. Impersonates the domain's configured ``dmarc_rua_mailbox`` (a real user -- domain-wide delegation cannot act as a group or alias; config default ``postmaster@<domain>``), searches it for mail addressed to ``dmarc_rua_recipient`` (the ``rua=mailto:`` address published in DNS, e.g. the ``postmaster+rua@`` plus-subaddress; default: same as the mailbox), reads the aggregate reports those messages carry, and answers "how much of this domain's mail volume is passing DMARC, and if we moved the policy to ``p=reject``, what would actually get blocked?" A record counts as PASS when either its aligned DKIM or SPF check reads "pass" (DMARC's own OR semantics) — NOT when both do. A record counts as a reject candidate when it is already quarantined or rejected (a tenant already running `p=quarantine`/`p=reject` enforces this today), or when BOTH checks fail with `disposition="none"` (what a stricter policy would newly start blocking); everything else is a pass. This distinction matters a lot in practice: a large share of legitimate mail (mailing-list forwards, some relays) passes DMARC via SPF alignment alone with DKIM unaligned, so counting on DKIM alone overstates the failure rate severalfold. ``reject_candidate_ips`` (the top ``top`` per domain by volume) is where to actually look before flipping a policy to ``p=reject``: a header_from that names a SUBDOMAIN of the audited domain is governed by that subdomain's own ``sp=`` policy, not the parent's ``p=``, so it is not necessarily what a `p=reject` change on the parent would affect. Requires the ``gmail.readonly`` DWD scope — the same one ``gmail_message_trace`` needs, granted PER SERVICE ACCOUNT CLIENT ID in the Admin console (Security > API controls > Domain-wide delegation), separately from the ``admin.directory.*`` / ``admin.r

Parameterstophoursdomainmailboxmax_pagesrecipient

group_delivery_policy

Check a Google Group's own posting/delivery policy — why an external sender's mail never arrived. A Group's access-control layer sits IN FRONT of Gmail delivery: when ``who_can_post`` is restricted (e.g. domain-members-only), an external sender's message is rejected there and never generates a per-recipient Gmail delivery event at all — ``gmail_message_trace`` (a real mailbox) and any Reports-API-based delivery trace both see nothing for that address, indistinguishable from a genuine delivery failure without this. Use this FIRST when a group address "isn't receiving" mail from an external sender, before chasing it as a transport/spam problem. Read-only: only ``groups().get()`` is issued (Groups Settings API). Requires the ``apps.groups.settings`` DWD scope — granted PER SERVICE ACCOUNT CLIENT ID in the Admin console (Security > API controls > Domain-wide delegation), separately from every other scope this server uses, and NOT on by default. Returns ``who_can_post`` (e.g. ``ALL_IN_DOMAIN_CAN_POST`` blocks external senders entirely; ``ANYONE_CAN_POST`` allows them), ``allow_external_members``, ``is_archived``, ``message_moderation_level``, ``spam_moderation_level``, ``allow_web_posting``. Sets ``found: false`` (no policy fields) when ``group_email`` does not name any group in this domain — that is a normal, expected answer for a bad/typo'd address, not an ``error``. Args: group_email: The group's address (e.g. "[email protected]"). domain: Configured ``[domain.*]`` section to route the lookup through. Default: resolved from the address's suffix.

Parametersdomaingroup_emailrequired

list_group_members

List a Google Group's basic metadata and member roster (Directory API). Resolves a group's actual membership directly, independent of any specific message ever having been sent to it — unlike inferring membership from Reports API delivery-event fanout (``applicationName=gmail``), which only shows members who received one PARTICULAR message and requires one to already exist to trace. Pair with ``gmail_message_trace`` to deep-dive a specific member's mailbox once the roster is known, or with ``group_delivery_policy`` to see why the group as a whole may not be receiving mail at all. Read-only: only ``groups().get()`` and ``members().list()`` are issued (Directory API), never a mutating call. Requires the ``admin.directory.group.readonly`` and ``admin.directory.group.member.readonly`` DWD scopes — granted PER SERVICE ACCOUNT CLIENT ID in the Admin console, separately from every other scope this server uses, and NOT on by default. The two calls are independent: a tenant with only one of the two scopes granted still gets that one section, with the other reported as ``{"error": ...}`` in its place rather than failing the whole call — only when BOTH fail does the tool return a single top-level ``error``. Sets ``found: false`` (no ``group``/``members`` sections) when ``group_email`` does not name any group in this domain — a normal, expected answer for a bad/typo'd address, not an ``error``. This triggers both when BOTH calls agree with no error on either side, AND when one call CONFIRMS not-found while the other independently failed (its own error is then attached as ``group_lookup_error`` / ``members_lookup_error``) — a confirmed non-existence from one independently-scoped call is stronger evidence than an unrelated failure on the other, and must not be buried under it. Args: group_email: The group's address. domain: Configured ``[domain.*]``

Parametersdomainmax_pagesgroup_emailrequired

drive_external_sharing

Report Drive ACL grants to external targets and new link/public exposure. Counts grants whose target (``target_user`` address, or ``target_domain`` for domain-scoped grants) is outside the configured internal domains (revocations are reported separately, not as exposure) and visibility transitions into link/public access (``people_with_link`` / ``public_on_the_web``, excluding a narrowing from public down to link-only; Google's ``shared_externally`` is owner-domain relative, so external-ness is judged by the target instead). ``untargeted_external_transitions`` counts transitions into ``shared_externally`` with no target address or domain (e.g. scope became "anyone with the link" — ``target_domain: "all"`` — or an unresolved target); it is a residual bucket, not a cross-check for missed named grants, since domain-scoped grants are already classified above. ``external_samples`` / ``exposure_samples`` / ``untargeted_samples`` hold examples of each. A self-grant of ``owner`` on ``change_user_access`` /``change_acl_editors`` (a user creating their own new file — every Form/Sheet/Doc submission does this) is excluded from ``risky_visibility_events``: it always reports a visibility transition from a missing prior state, which is indistinguishable from a genuine narrow-to-wide exposure event by visibility fields alone, but is not exposure of anything pre-existing. When no ``target_user`` is named (the ``change_acl_editors`` shape), a missing target alone is not proof of creation — an admin bulk-transferring ownership of a pre-existing, already-shared file (e.g. offboarding) looks the same — so this case is corroborated against the event's own ``owner`` parameter (self-action only if ``owner`` matches the actor); if ``owner`` itself is absent the conservative default is to count it rather than risk dropping a real ownership change. This exclusion is deliberately never

Parametershoursdomainsamplesmax_pages

drive_doc_activity

Full audit history of one Drive document: owner, ACL changes, lifecycle. The triage companion to ``drive_external_sharing``: a sharing finding names a ``doc_id``, and judging it requires who OWNS the document (an individual user vs. a shared drive completely changes the risk read) and its grant history over time. Uses a server-side ``doc_id`` filter, so the page budget is spent on this one document only. ``owner`` / ``doc_title`` are taken from the document's own events (a shared-drive-owned file reports the drive's name — not a user address — as owner). ``events`` lists ACL and lifecycle events newest-first (view/edit/download noise is counted in ``event_counts`` but not listed); ``events_truncated`` is set when more matched than ``max_events``. The ``doc_id`` filter matches at the ACTIVITY level and one activity can carry sibling events for OTHER documents (a multi-file share is one activity with one event per file) — events whose own ``doc_id`` parameter does not match (or is absent) are excluded from every output field and tallied in ``sibling_events_skipped`` instead, so a bulk action cannot contaminate this document's history or misattribute its owner. An empty result means no events in the window for the queried tenant — NOT proof the document does not exist (history older than the Reports API retention, or a document living in a different tenant, looks the same). Args: doc_id: Drive document id (from a sharing finding's ``doc_id`` field). days: How far back to scan (Reports API retains roughly 6 months). domain: Restrict to one configured domain/tenant (default: all). max_pages: Reports page cap per domain; ``capped=true`` means more existed. max_events: Cap on listed events (counts are unaffected).

Parametersdaysdoc_idrequireddomainmax_pagesmax_events

shared_drive_membership_changes

Membership add/remove/role-change history across shared drives. Answers "who added this (external) member, and when" — the other half of triaging a shared-drive sharing finding (see ``drive_doc_activity``). Membership changes are low-volume, so a plain window scan of the single ``shared_drive_membership_change`` event works even over months. Each entry's ``drive`` is the shared drive's NAME as the audit log reports it (the event's ``owner`` parameter — not an id, not a user address); ``target_is_external`` classifies the affected member against the configured internal domains. The Reports API cannot filter by drive server-side, so ``drive_name`` is a client-side case-insensitive substring match on that name — it narrows the listing, not the scan. An event whose drive name is absent can neither match nor be ruled out; with ``drive_name`` set such events are excluded from ``total``/``entries`` but tallied in ``missing_drive_name`` so the drop is never silent (without ``drive_name`` they are listed normally with ``drive: null``). Args: days: How far back to scan (Reports API retains roughly 6 months). domain: Restrict to one configured domain/tenant (default: all). drive_name: Only list entries whose drive name contains this substring. max_pages: Reports page cap per domain; ``capped=true`` means more existed. max_events: Cap on listed entries (``total`` counts all matches).

Parametersdaysdomainmax_pagesdrive_namemax_events

admin_actions

What administrators did in the Admin console: to one account or group, or tenant-wide. Answers "did someone (IT staff, a sync job) suspend this account, reset its password, move it to another org unit, or take it out of a group?" from the Admin console audit log (Reports API ``applicationName=admin``). Each row names the ``actor`` (the administrator's address, or a profile id for a service account or sync client), ``actor_ip``, the ``event_type`` (``USER_SETTINGS``, ``GROUP_SETTINGS``, ...), the ``event`` (``SUSPEND_USER``, ``UNSUSPEND_USER``, ``CHANGE_PASSWORD``, ``CHANGE_USER_ORGANIZATION``, ``ADD_GROUP_MEMBER``, ``REMOVE_GROUP_MEMBER``, ``CREATE_USER``, ``DELETE_USER``, ``CHANGE_APPLICATION_SETTING``, ...) and its ``parameters`` as the API names them (``USER_EMAIL``, ``GROUP_EMAIL``, ``ORG_UNIT_NAME``, ``SETTING_NAME``, ``OLD_VALUE``, ``NEW_VALUE``, ...; long values cut at 300 characters). What the account did itself (its own password change, a second factor it enrolled) is not here: use ``user_activity``. With ``target`` (a user or group address), the domain is taken from its suffix and two server-side filters run, ``USER_EMAIL==<target>`` and ``GROUP_EMAIL==<target>``; an event of a bulk activity that names some other address is skipped and counted in ``sibling_events_skipped``, as is a sibling of another name when ``event`` is given. Without it, every admin action of the window is read (each configured domain, or ``domain``). ``event`` narrows to one event name. Per domain: ``events`` newest first (cut at ``max_events``, ``events_truncated``), ``by_event`` and ``by_actor`` counts over every matching event, ``capped`` (more pages existed) and ``errors`` (a probe that failed; a domain whose credentials failed is ``{"error": ...}``). ``capped`` at the top is set when any domain was capped or failed. Reports data can lag by hours, so an empty window right

Parameterseventhoursdomaintargetmax_pagesmax_events

workspace_status

Is a Google Workspace service down? Open and recent incidents from Google's status dashboard. Reads the public Google Workspace Status Dashboard feed (no credential, no DWD scope): the incidents Google has declared for Gmail, Drive, Meet, Calendar, Chat and the other Workspace products. ``open`` lists the incidents not yet resolved; ``recent`` those that ended within the last ``hours``. Each row: ``service``, ``affected_products``, ``severity``, ``status_impact``, ``begin`` / ``end`` (UTC), ``summary`` (the latest update's summary line), ``last_update`` and the incident's ``url``. The dashboard is global: an empty answer means Google has declared no incident, not that this tenant cannot be affected, and a local problem (a network path, one account) never appears here. Args: hours: How far back ``recent`` reaches (at most 2160, 90 days). service: Keep incidents whose service or affected product name contains this text (case-insensitive), e.g. ``Gmail``.

Parametershoursservice

daily_brief

One-call security summary across all configured domains. Aggregates login_audit (account locks, suspicious logins), account-protection changes (``account_changes``: 2-Step Verification enrolled/disabled, passkeys added/removed, Advanced Protection turned off, password and recovery address/phone edits, mail forwarding out of the domain; each row says whether it came from outside the ``GWSADM_SITES_INI`` table and from which country (``GWSADM_IP2ASN``), and is ``flagged`` with ``flag_reasons`` -- ``foreign`` (outside ``GWSADM_HOME_COUNTRIES``), ``unknown_origin``, ``after_suspicious_login`` -- flagged rows first, at most 50 listed, ``total`` counts all; ``suspicious_login_coverage`` says whether the last reason is complete) and drive_external_sharing (external grants, new link exposure, and ``untargeted_external_transitions`` — see that tool's docstring). ``max_pages`` / ``samples`` are passed through to the drive scan; ``max_pages`` defaults to the same page budget as the standalone tool, so both report the same counters for the same window (``samples`` defaults lower here and only trims the example lists). Per-domain ``capped`` in the summary means at least one underlying scan was partial — treat that domain's counts as lower bounds (see ``capped_events`` in the drive section for which probes were cut short). Synchronous: on a large tenant this can exceed a client's ~60s tool-call timeout. If it does, use ``daily_brief_start`` + ``daily_brief_result`` (same result, run in the background) or lower ``max_pages``.

Parametershourssamplesmax_pages

daily_brief_start

Start a daily_brief in the background; returns immediately with a ``job_id``. Use this instead of ``daily_brief`` when the synchronous call risks the client's ~60s tool-call timeout (large tenants). Args mirror ``daily_brief``. You MUST then poll ``daily_brief_result(job_id)`` every few seconds until ``status`` is ``done`` (the full daily_brief payload is under ``result``) or ``error``. On a config error returns ``{"error": ...}``; if too many jobs are already active returns ``{"status": "rejected", ...}``.

Parametershourssamplesmax_pages

daily_brief_result

Fetch a ``daily_brief_start`` job by id. ``status`` is ``running`` (keep polling), ``done`` (``result`` holds the full daily_brief payload), ``error`` (``error`` holds the exception type name — the message is omitted to avoid leaking internal detail), or ``unknown`` (bad/expired id).

Parametersjob_idrequired

Version history

9
  1. v0.23.1LatestOct 26, 2026
  2. v0.23.0Oct 24, 2026
  3. v0.22.1Oct 22, 2026
  4. v0.21.0Oct 18, 2026
  5. v0.20.0Oct 17, 2026
  6. v0.19.0Oct 17, 2026
  7. v0.18.0Sep 29, 2026
  8. v0.17.0Sep 23, 2026
  9. v0.16.0Sep 16, 2026

Score

90% of the applicable checks met

How complete this listing is, computed from the registry entry, the endpoint and public sources. Not a rating of the server and not a statement that it is safe or working today: reachability is a dated check, and no installs or reviews go into the score.

  • Reached in the last 30 days25 pts
  • Auth declared10 pts

    Declare the credential headers in the registry entry, or keep the endpoint answering the probe so its auth can be classified.

  • Tools listedRequired15 pts
  • DescriptionRequired15 pts
  • Logo10 pts
  • Documentation15 pts
  • License10 pts

Other Audit integrations