{
  "markdown": "<!-- mcp-name: io.github.shigechika/gwsadm-mcp -->\n\n# gwsadm-mcp\n\nEnglish | [日本語](README.ja.md)\n\nGoogle Workspace **security-audit** MCP (Model Context Protocol) server —\nread-only visibility into account locks, suspicious logins, and external file\nsharing, built on the Admin SDK Reports API (audit activities).\n\nNamed after the admin-console viewpoint (`gwsadm` = Google Workspace admin),\nsibling of [`boxadm-mcp`](https://github.com/shigechika/boxadm-mcp). This is\n**not** a general-purpose Workspace MCP: it surfaces risk, it never mutates\nanything.\n\n## Features\n\n| Tool | Description |\n|------|-------------|\n| `health_check` | Server version, config path, and per-domain auth probe — call at session start or after a timeout |\n| `login_audit` | Reports API `login` — accounts **auto-disabled by Google** (`account_disabled_*`: leaked password, hijacked, spamming), suspicious logins, failure top-N |\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 |\n| `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 |\n| `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 |\n| `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 |\n| `drive_external_sharing` | Reports API `drive` — ACL **grants** to external addresses or domains (revocations reported separately) and visibility **transitions** into link/public exposure |\n| `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 |\n| `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 |\n| `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\" |\n| `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 |\n| `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) |\n| `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) |\n| `daily_brief` | One-call summary across all configured domains |\n| `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 |\n\nPlanned: `dlp_events` (Reports `rules`; requires a Workspace edition with DLP),\n`token_events`, `admin_events`.\n\n## Auth model\n\nService account with **domain-wide delegation (DWD)** impersonating an\naudit-capable admin. Fully non-interactive — no browser, no token refresh\nrotation — so the server runs unattended (cron, MCP gateway, CI).\n\nGrant **all** of the following DWD scopes on the same service-account client\nID up front, in one setup pass. Adding them one at a time as each tool gets\nbuilt is how a scope goes missing until the one tool that needed it starts\ndegrading — one place, one pass, avoids the trap:\n\n| Scope | Needed by | Missing it |\n|-------|-----------|------------|\n| `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 |\n| `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 |\n| `https://www.googleapis.com/auth/admin.directory.user.security` | `user_oauth_tokens` | that tool degrades to a per-domain error; everything else keeps working |\n\n`health_check` needs no scope at all to respond: it is the tool to call when\na grant might be missing — it probes each domain and reports the failing\nauth in a structured per-domain result instead of failing itself.\n\n`gmail_usage_report` needs its own separate scope too, despite living under\nthe same Admin SDK Reports API as the base pass above — the \"Usage report\"\nfamily (`customerUsageReports`) and the \"Audit\" activity stream\n(`activities().list`, everything else in the base pass) are gated by two\ndifferent scopes, and having one does not imply the other:\n\n| Scope | Needed by | Missing it |\n|-------|-----------|------------|\n| `https://www.googleapis.com/auth/admin.reports.usage.readonly` | `gmail_usage_report` | that tool degrades to a per-domain error; everything else keeps working |\n\n`gmail_message_trace` and `dmarc_rua_summary` need one more scope, granted as\na **separate** step — it is intentionally not bundled into the pass above:\n\n| Scope | Needed by | Missing it |\n|-------|-----------|------------|\n| `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 |\n\nThis is a materially broader grant than the three above: it allows reading\n*message content* for any user the service account impersonates, not just\nmetadata. `gmail_message_trace` only ever requests `format=\"metadata\"` — it\nnever reads a message body — but `dmarc_rua_summary` DOES read content: it\nfetches the compressed DMARC report attachment each RUA message carries\n(`format=\"full\"` plus `attachments().get()`) and parses it. Both stay within\nwhat the grant allows either way, but only `gmail_message_trace` stays inside\nthe narrower \"metadata only\" habit; the narrower `gmail.metadata` scope was\nconsidered and rejected for both tools because it does not support the `q=`\nsearch parameter the `rfc822msgid:`/RUA-mailbox lookups need. Grant it on the\n**same** service-account client ID as the other scopes (Admin console →\nSecurity → API controls → Domain-wide delegation → find the existing client\nID → add this scope to its list), and weigh that broader exposure against how\nmuch you actually need message-trace/DMARC reporting before turning it on for\na given domain.\n\n`group_delivery_policy` and `list_group_members` each need their own\nseparate scope too — three more grants beyond the base pass, none bundled\nwith each other or with `gmail.readonly` above:\n\n| Scope | Needed by | Missing it |\n|-------|-----------|------------|\n| `https://www.googleapis.com/auth/apps.groups.settings` | `group_delivery_policy` | that tool degrades to an error; everything else keeps working |\n| `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 |\n| `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 |\n\nThe Groups Settings API is a distinct product from the Directory API, hence\nthe separate scope; it has no readonly-only variant, but this server only\never calls `groups().get()`, never a mutating method.\n\n`suspended_accounts`, `get_user` and `user_oauth_tokens` all operate per\nconfigured domain (Directory `domain=`/`userKey=`), unlike the customer-wide\nReports tools — so every domain you want covered (e.g. a separate student\ndomain) needs its own `[domain.*]` config section. Note the failure modes\ndiffer: `suspended_accounts` **silently omits** an unconfigured domain from\nits result, while `get_user` and `user_oauth_tokens` fail loudly with an\nunknown-domain error (both take a `domain` override for an alias/secondary\naddress whose suffix has no section of its own).\n\n## Setup\n\n```bash\n# uv\nuv pip install gwsadm-mcp\n\n# pip\npip install gwsadm-mcp\n```\n\nOr from source:\n\n```bash\ngit clone https://github.com/shigechika/gwsadm-mcp.git\ncd gwsadm-mcp\n\n# uv\nuv sync\n\n# pip\npip install -e .\n```\n\n## Configuration\n\nPoint `GWSADM_CONFIG` at an INI file (default `~/.config/gwsadm-mcp/config.ini`,\nkeep it `0600`):\n\n```ini\n[gwsadm]\n# optional; defaults to all [domain.*] section names\ninternal_domains = example.edu, mail.example.edu\n\n[domain.example.edu]\nservice_account_file = /path/to/service-account.json\nsubject = audit-admin@example.edu\ncustomer_id = C0xxxxxxx\ndmarc_rua_mailbox = postmaster@example.edu   # optional, default: postmaster@<domain>; \"none\" opts out\ndmarc_rua_recipient = postmaster+rua@example.edu   # optional, default: same as dmarc_rua_mailbox\n```\n\nOne `[domain.*]` section per audited Workspace domain. `internal_domains` is\nthe allowlist used to classify sharing targets as internal vs external.\n`dmarc_rua_mailbox` is the real user `dmarc_rua_summary` impersonates to read\nDMARC aggregate reports — domain-wide delegation can only act as an actual user,\nnever as a group or alias. `dmarc_rua_recipient` is the address the reports are\nsent to (the `rua=mailto:` value published in the domain's `_dmarc` record) and\nis used only to narrow the Gmail search (`to:<recipient>`); it defaults to the\nmailbox. Set it when the published address is a Gmail plus-subaddress such as\n`postmaster+rua@` (searching on it also keeps `ruf=` failure reports sent to\n`postmaster+ruf@` out of the aggregate parse) or a group that fans out to the\nimpersonated inbox. `dmarc_rua_mailbox = none` opts a domain out of DMARC reading\n— e.g. when its `rua=` points at another domain's mailbox that a different\n`[domain.*]` section already reads; reports are grouped by the policy domain each\nreport names, so they still appear under that other section.\n\n## Usage\n\n### Claude Code (plugin)\n\nThis repository doubles as a single-plugin marketplace, so Claude Code can\ninstall the server for you:\n\n```\n/plugin marketplace add shigechika/gwsadm-mcp\n/plugin install gwsadm-mcp@gwsadm-mcp\n```\n\nThe plugin launches `uvx gwsadm-mcp` and reads `GWSADM_CONFIG` (falls back to\n`~/.config/gwsadm-mcp/config.ini`), the same variable described in\n[Configuration](#configuration). `/plugin install` only wires up the server\nprocess — it cannot create the config INI or the Google Cloud service-account\nJSON key(s) it points at; both must already exist on the machine running the\nplugin before any tool call will succeed.\n\n`uvx` must be on the `PATH` of the process that runs Claude Code — a login\nshell usually has it, but a GUI-launched app may not; install\n[uv](https://docs.astral.sh/uv/) system-wide if the plugin fails to start.\n\n### Claude Code (manual)\n\nAdd to `.mcp.json` (no `env` needed when the config lives at the default path;\nadd `\"env\": { \"GWSADM_CONFIG\": \"...\" }` only for a non-default location):\n\n```json\n{\n  \"mcpServers\": {\n    \"gwsadm-mcp\": {\n      \"type\": \"stdio\",\n      \"command\": \"gwsadm-mcp\"\n    }\n  }\n}\n```\n\n### Claude Desktop\n\nAdd the same entry to `claude_desktop_config.json`.\n\n### Direct Execution\n\n```bash\ngwsadm-mcp\n```\n\n### CLI Options\n\n```bash\ngwsadm-mcp --version   # Print version and exit\ngwsadm-mcp --check     # Config + auth + API smoke for every domain, then exit\ngwsadm-mcp             # Start MCP server (STDIO, default)\n```\n\n`--check` exit codes: `0` success, non-zero on config or auth failure.\n\n## Notes\n\n- Every result section reports `capped: true` when a window exceeded the page\n  budget, or when a probe's fetch errored outright (see `event_errors`) —\n  partial coverage is never presented as \"no findings\". The drive scan also\n  reports `capped_events` (which eventNames were cut short). Narrow `hours`\n  or raise `max_pages` for full coverage — on a large tenant, term-time\n  weekdays can produce thousands of `change_user_access` events/day.\n- Google's `visibility=shared_externally` is relative to the file **owner's**\n  domain, so with multiple `internal_domains` a cross-internal-domain grant\n  (e.g. student domain → staff domain) carries it too. External-ness is\n  therefore judged against `internal_domains` using the grant's target:\n  `target_user` for named grants, `target_domain` for domain-scoped grants\n  (e.g. \"anyone at partner.edu\"; the literal domain `\"all\"` means \"anyone\n  with the link\" and is judged by visibility instead). `risky_visibility_events`\n  counts only transitions into `people_with_link` / `public_on_the_web`\n  (excluding a narrowing from public down to link-only).\n  `untargeted_external_transitions` is a residual bucket for transitions into\n  `shared_externally` with no target address or domain to classify — it is\n  not a cross-check for grants missed elsewhere, since domain-scoped grants\n  are already counted above. `external_samples` / `exposure_samples` /\n  `untargeted_samples` hold examples of each.\n- Drive events are queried **one audit-relevant eventName at a time**, so the\n  page budget is not consumed by view/edit noise; an event name rejected by the\n  API degrades into `event_errors` instead of failing the tool.\n  `change_document_visibility` and `change_document_access_scope` report the\n  same transition as simultaneous sibling events on this API — only the\n  latter drives classification (the former is fetched for its `acl_events`\n  count only), so a domain-scoped grant or a link/public exposure is never\n  double-counted across the two. This also means the former can no longer\n  compensate if the latter's own fetch fails: a `change_document_access_scope`\n  entry in `event_errors` sets `capped: true` for that domain, and its\n  classification counts for the window are a lower bound even though\n  `change_document_visibility` (and thus `acl_events`) may show data.\n- A failure in one domain degrades only that domain's section (`{\"error\": ...}`).\n- `gmail_message_trace` sets `ambiguous: true` (with `match_count`) on a\n  recipient whose mailbox has more than one message under the same\n  Message-ID (mailing-list copy plus a direct CC, a quarantine-release\n  duplicate, …) — the rest of that recipient's fields describe only the\n  first match, not a combined answer. `match_count_capped` is set alongside\n  it when the mailbox has enough matches that `match_count` is a lower\n  bound rather than exact (the search does not paginate).\n- `get_user` distinguishes \"this address names no account\" from \"the lookup\n  failed\": a plain HTTP 404 answers `found: false` with no state fields,\n  which is a diagnostic result — a typo'd or deleted address — and never an\n  `error`. A missing DWD scope or a transient failure answers `{\"error\": ...}`\n  with no `found` key instead, so the two can never be confused in either\n  direction. Fields Google omits stay `null` rather than being coerced:\n  a missing `suspended` must not read as \"the account is fine\".\n- `group_delivery_policy` normalizes the Groups Settings API's `\"true\"`/`\"false\"`\n  string fields (a quirk of that API, not JSON booleans) into real booleans in\n  its output; a field absent from Google's response stays `null`, never\n  coerced to `false`. `list_group_members` runs its group-metadata and\n  member-roster lookups independently — a tenant with only one of the two\n  DWD scopes still gets that one section, the other reported as\n  `{\"error\": ...}` in its place. It reports `capped: true` both when the\n  member roster exceeded its page budget (default 20 pages × 200/page) and\n  when the member lookup failed outright (see `members_error`) — either\n  way the roster is not the full one, and an empty `members` list must\n  never be read as a confirmed-empty group when `capped` is true.\n  Both group tools distinguish \"this address is not a group\" (a plain HTTP\n  404, verified against production for all three underlying API calls) from\n  a real failure: `group_delivery_policy` sets `found: false`;\n  `list_group_members` sets it too, when either both independent lookups\n  agree with no error on either side, OR one CONFIRMS not-found while the\n  other independently failed (that failure is then attached as\n  `group_lookup_error` / `members_lookup_error` rather than hidden) — a\n  confirmed non-existence outweighs an unrelated error on the other scope.\n  Only a genuine mixed state (one side not-found, the other actually\n  finding data) falls through to the normal per-section shape instead.\n- Read-only by design: `activities().list` (Reports API), `users().list` /\n  `users().get` / `tokens().list` / `groups().get` / `members().list` (Directory API),\n  `groups().get` (Groups Settings API), and `messages().list` / `messages().get`\n  (Gmail API, metadata only) are the only API calls issued anywhere in this\n  package.\n- Output contains account addresses (that is the point of an audit tool):\n  restrict access to authorized security staff. `gmail_message_trace` also\n  returns a message snippet and headers (From/To/Cc/Subject/Date) for a\n  matched message — treat its output with the same care as the mailbox\n  content it is drawn from.\n\n## Development\n\n```bash\ngit clone https://github.com/shigechika/gwsadm-mcp.git\ncd gwsadm-mcp\n\n# uv\nuv sync --dev\nuv run pytest -v\nuv run ruff check .\n\n# pip\npython3 -m venv .venv\n.venv/bin/pip install -e . && .venv/bin/pip install pytest ruff\n.venv/bin/pytest -v\n.venv/bin/ruff check .\n```\n\n### Live smoke test\n\nThe unit suite never talks to Google, which is what makes it fast — and also\nwhat makes it blind to a tool that has stopped returning real data.\n`scripts/smoke_test.py` runs **every registered tool** against the configured\ntenant and fails on empty, malformed or error answers:\n\n```bash\n# uses the same config file as the server (GWSADM_CONFIG)\nuv run python scripts/smoke_test.py\nuv run python scripts/smoke_test.py --only oauth --traceback\n```\n\n- **Read-only.** Every tool here reads an audit log or a directory snapshot;\n  nothing in Workspace is changed. `daily_brief_start` creates a job inside the\n  process, which expires on its own.\n- **No payloads in the report.** Tool names, statuses and row counts only;\n  server-authored error text is redacted too, since these tools deal in account\n  addresses and document titles throughout.\n- **Bounded.** Every bounding parameter a tool offers is passed explicitly —\n  the defaults (5 pages, 180 days, 200 events) are sized for a human asking\n  once, and are enforced by a test that finds them from the source.\n- **Nothing tenant-specific in the specs.** The account and the document the\n  per-user and per-document tools need are discovered at run time, and skipped\n  when the tenant has none to offer. Two tests keep it that way: one refuses\n  those parameters as literals, the other bans anything address-shaped anywhere\n  in the file, because this repository is public.\n- An empty answer passes: no external sharing and no locked accounts is the\n  desired state. What is asserted instead is the envelope — and, where the\n  answer is keyed by domain, that the domain map is not empty, since a config\n  resolving to zero domains would otherwise report every tool as working while\n  auditing nothing.\n- CI enforces the cheap half: a tool registered without a probe spec fails the\n  build (`tests/test_smoke_probes.py`), so adding a tool forces the question\n  \"how would we know it works?\".\n- `scripts/smoke_harness.py` is the engine and holds no Workspace knowledge: it\n  is kept identical across the servers that share it, so fix engine bugs once\n  and sync the file rather than patching this copy.\n\n## Releasing\n\nReleases are automated with [release-please](https://github.com/googleapis/release-please).\nMerging [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, …)\nto `main` keeps a release PR open with the next version and changelog. Merging\nthat PR tags `vX.Y.Z` and publishes a GitHub Release, whose `release: published`\nevent triggers the `release` workflow to build and publish to PyPI and the MCP\nRegistry. release-please owns the version in `gwsadm_mcp/__init__.py` and\n`server.json` (do not bump them by hand).\n\n> [!IMPORTANT]\n> The release-please workflow should be given a repository secret\n> `RELEASE_PLEASE_TOKEN` (a PAT with `contents: write` + `pull-requests: write`).\n> The default `GITHUB_TOKEN` cannot create the Release that triggers the\n> downstream `release` workflow (GitHub blocks workflow runs triggered by\n> `GITHUB_TOKEN`), so without the PAT nothing gets published. The workflow falls\n> back to `GITHUB_TOKEN` when the secret is unset so PR CI keeps working on forks.\n\n## License\n\nMIT\n",
  "bytes": 23133,
  "sha": "064a1fbd9449b12460fb34ac802b3276ce26a3a1176d24c5ca76ea65a6ca31c1",
  "repo_slug": "shigechika/gwsadm-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_shigechika_gwsadm_mcp_c7d3f1bb/readme"
}