The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the Google Workspace Admin Security Audit MCP listing page.
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, it never mutates
anything.
| Tool | Description |
|---|---|
health_check | Server version, config path, and per-domain auth probe — call at session start or after a timeout |
login_audit | Reports API login — accounts auto-disabled by Google (account_disabled_*: leaked password, hijacked, spamming), suspicious logins, failure top-N |
gmail_usage_report | Reports 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_accounts | Directory 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_user | Directory 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_tokens | Directory 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 |
drive_external_sharing | Reports API drive — ACL grants to external addresses or domains (revocations reported separately) and visibility transitions into link/public exposure |
drive_doc_activity | Reports 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_changes | Reports 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 |
gmail_message_trace | Gmail 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_summary | Gmail 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_policy | Groups 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_members | Directory 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_brief | One-call summary across all configured domains |
daily_brief_start / daily_brief_result | Same 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, admin_events.
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:
| Scope | Needed by | Missing it |
|---|---|---|
https://www.googleapis.com/auth/admin.reports.audit.readonly | login_audit, 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.readonly | suspended_accounts, get_user | those two tools degrade to an error (per-domain for suspended_accounts); everything else keeps working |
https://www.googleapis.com/auth/admin.directory.user.security | user_oauth_tokens | that tool degrades 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:
| Scope | Needed by | Missing it |
|---|---|---|
https://www.googleapis.com/auth/admin.reports.usage.readonly | gmail_usage_report | that 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:
| Scope | Needed by | Missing it |
|---|---|---|
https://www.googleapis.com/auth/gmail.readonly | gmail_message_trace, dmarc_rua_summary | 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:
| Scope | Needed by | Missing it |
|---|---|---|
https://www.googleapis.com/auth/apps.groups.settings | group_delivery_policy | that tool degrades to an error; everything else keeps working |
https://www.googleapis.com/auth/admin.directory.group.readonly | list_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.readonly | list_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).
Or from source:
Point GWSADM_CONFIG at an INI file (default ~/.config/gwsadm-mcp/config.ini,
keep it 0600):
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.
This repository doubles as a single-plugin marketplace, so Claude Code can install the server for you:
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.
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):
Add the same entry to claude_desktop_config.json.
--check exit codes: 0 success, non-zero on config or auth failure.
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.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.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.{"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.activities().list (Reports API), users().list /
users().get / tokens().list / groups().get / members().list (Directory API),
groups().get (Groups Settings API), and messages().list / messages().get
(Gmail API, metadata only) are the only API calls issued anywhere in this
package.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.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:
daily_brief_start creates a job inside the
process, which expires on its own.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.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 withcontents: write+pull-requests: write). The defaultGITHUB_TOKENcannot create the Release that triggers the downstreamreleaseworkflow (GitHub blocks workflow runs triggered byGITHUB_TOKEN), so without the PAT nothing gets published. The workflow falls back toGITHUB_TOKENwhen the secret is unset so PR CI keeps working on forks.
MIT