{
  "markdown": "<!-- mcp-name: io.github.shigechika/boxadm-mcp -->\n\n# boxadm-mcp\n\nEnglish | [日本語](README.ja.md)\n\nMCP (Model Context Protocol) server that surfaces **external file flow** from\na Box admin's point of view. It reads Box's enterprise event log\n(`admin_logs`) to highlight \"who shares a lot with the outside\" and \"which\nfiles get accessed from outside\" — an early-warning signal for leakage, not a\ngeneral-purpose file browser.\n\nDocumentation: <https://shigechika.github.io/boxadm-mcp/>\n\n**Read-only**: it never revokes shares, deletes files, or otherwise mutates\nanything — it only surfaces risk. This is a different tool from a\ngeneral-purpose Box file MCP (the official Box MCP, or the claude.ai Box\nconnector): those operate on a user's own files and cannot see enterprise\nevents, which is exactly what this server is for.\n\nNamed after the admin-console viewpoint (`boxadm` = Box admin), sibling of\n[`gwsadm-mcp`](https://github.com/shigechika/gwsadm-mcp).\n\n## Features\n\n| Tool | Category | Description |\n|------|------|-------------|\n| `health_check` | — | version + auth_mode + Box auth + `admin_logs` scope probe + configured domain allowlist. Reports `needs-login` when not yet authenticated (OAuth mode) |\n| `recent_admin_events` | Diagnostic | Raw recent enterprise events (for checking event types/fields). Supports manual pagination via `stream_position` |\n| `external_access_events` | Access (events, enterprise-wide) | Aggregates external DOWNLOAD/PREVIEW within a window: top external accessors, top externally-accessed files, share-link count. Pass `created_by_logins` for **DLP tracing** of a specific account |\n| `external_collaborators` | Exposure (enumeration) | Lists external collaborators (outside-org login or external invite email) |\n| `public_shared_links` | Exposure (enumeration) | Lists items shared with an `open` (anyone-with-the-link) share link |\n| `top_external_sharers` | Exposure (enumeration) | Ranks internal owners by external exposure (external collabs + public links) |\n| `list_folder_items` | One folder (`ls`) | Names, upload time, size, **who uploaded**, and a direct link per item. Filter by uploader or upload-time window. Reads no file content |\n| `get_user` | Account state (lookup) | One account by its **exact login**: `status`, `role`, `enterprise`, quota, timestamps. Answers \"is this account disabled?\" without an admin console |\n| `daily_brief` | Combined | Morning summary combining access (events) and exposure (enumeration) |\n\n## Auth model\n\nTwo modes, selected via `BOX_AUTH_MODE`:\n\n- `oauth` — OAuth 2.0 (user auth). An admin authorizes once in a browser; the\n  refresh token keeps it running unattended after that.\n- `ccg` — Client Credentials Grant (server-to-server). Simpler to run\n  unattended if your Box tenant has an available server-authentication app\n  slot.\n\n`admin_logs` (enterprise events) is readable in **either mode**, provided the\nauthorizing/impersonated user is an admin and the app has the **Manage\nenterprise properties** scope.\n\n### OAuth setup (one-time, by a Box admin)\n\n1. Developer Console → Create Platform App → **Custom App → User\n   Authentication (OAuth 2.0)**\n2. **Redirect URI**: `http://localhost:8787/callback`\n3. **Application Scopes**: check **Manage enterprise properties** (required\n   for `admin_logs`). Add **Read all files and folders** too if you also want\n   collaboration/share-link enumeration, and **Manage users** if you want the\n   `get_user` lookup (each scope change requires re-consent via\n   `boxadm-mcp auth`)\n4. Enable the app in the Admin Console (unpublished apps are disabled by\n   default under most tenant policies)\n5. Note the **Client ID / Client Secret**\n6. First login: set `BOX_AUTH_MODE=oauth` etc., then run **`boxadm-mcp\n   auth`** → authorize in the browser → a token cache is written to\n   `~/.config/boxadm-mcp/token.json` (chmod 600)\n\n## Setup\n\n```bash\n# uv\nuv pip install boxadm-mcp\n\n# pip\npip install boxadm-mcp\n```\n\nOr from source:\n\n```bash\ngit clone https://github.com/shigechika/boxadm-mcp.git\ncd boxadm-mcp\n\n# uv\nuv sync\n\n# pip\npip install -e .\n```\n\n## Configuration\n\n| Variable | Required | Description |\n|---|---|---|\n| `BOX_AUTH_MODE` | | `oauth` / `ccg` (default `ccg`). Any other value falls back to `ccg`; `health_check` reports the mode in effect, so a typo shows up there as `ccg` rather than as what was typed |\n| `BOX_CLIENT_ID` | ✓ | App Client ID |\n| `BOX_CLIENT_SECRET` | ✓ | App Client Secret |\n| `BOX_ENTERPRISE_ID` | ccg mode | Enterprise ID (CCG subject; not needed for oauth) |\n| `BOX_OAUTH_REDIRECT_URI` | | oauth redirect. Default `http://localhost:8787/callback` |\n| `BOX_TOKEN_CACHE` | | oauth token cache path. Default `~/.config/boxadm-mcp/token.json` |\n| `BOX_API_BASE` | | Default `https://api.box.com` |\n| `BOX_SCAN_CONCURRENCY` | | Parallel per-folder lookups in the enumeration scan. Default `8`, clamped `1`–`32` |\n| `BOX_SCAN_DEADLINE` | | Soft wall-clock budget (seconds) for one enumeration scan. Default `45`; `0`/negative disables it. When hit, the scan returns a disclosed partial (`capped=true`) instead of running until the tool call times out |\n| `BOX_HTTP_TIMEOUT` | | Per-request HTTP timeout (seconds). Default `30`. Lower it (with `BOX_SCAN_DEADLINE`) so one slow endpoint can't stretch the final in-flight scan batch past a gateway timeout |\n| `BOX_ALLOWED_DOMAINS` | ✓ | Internal email domains (comma-separated). No default — every address counts as external until you set this |\n\nKeep secrets out of `.mcp.json` (e.g. in a local env file sourced before\nlaunch); `.mcp.json` itself can reference `${BOX_CLIENT_ID}`-style variables\nand be safely committed.\n\n### Scope and limits\n\n- **Access tools** (`external_access_events`, and the access half of\n  `daily_brief`) read the **enterprise-wide** events stream. Hitting\n  `max_events` sets `capped: true` (oldest-first scan).\n- **Exposure (enumeration) tools** only see folders visible to the\n  co-admin account (not a guaranteed 100% of the enterprise), plus\n  `max_folders`/`max_depth` limits (surfaced via `capped`). Requires the\n  **Read all files and folders** scope.\n- The scan fans its per-folder lookups out concurrently\n  (`BOX_SCAN_CONCURRENCY`), since Box has no enterprise-wide collaboration\n  listing — this widens how many folders finish inside a tool-call timeout,\n  but coverage is still bounded by the caps. The read path retries `429`\n  (honoring `Retry-After`) and transient `5xx` with jittered backoff, so a\n  passing throttle recovers instead of degrading coverage; a folder dropped by\n  a per-folder API error that outlasts those retries (e.g. a persistent `403`)\n  is counted in `fetch_errors`: coverage is complete only when `capped` is\n  false **and** `fetch_errors` is 0.\n- Enumeration tools share a short-TTL scan memo across calls;\n  `public_shared_links` skips collaboration calls entirely (optimization).\n- **`get_user`** reads the enterprise **user directory** instead — one request,\n  no paging, and structurally not an enumerator (it answers about the login you pass and nothing\n  else). Its `capped` flag discloses a truncated search, so a `found: false`\n  from a truncated result reads as inconclusive rather than negative.\n\n### DLP tracing (reverse-lookup by accessor)\n\nTo answer \"what did this external account download\": pass\n`created_by_logins` (comma-separated logins) to `external_access_events`. It\nkeeps only that accessor's events and returns per-file detail\n(`matched_events`: item id/name, owner, size in bytes+GB, timestamp,\nevent_type, whether it was via a share link).\n\n```\nexternal_access_events(since_hours=26, created_by_logins=\"someone@example.com\")\n```\n\n- Since the accessor could appear anywhere in the window, a filtered call\n  auto-extends the scan cap to **up to 50,000 events** (oldest-first) — but\n  only matching events are kept, so memory stays bounded.\n- In this mode the response carries `events_matched` (match count) instead of\n  `events_scanned` (no running total is kept; use `capped` to judge coverage).\n  `capped: true` means the window wasn't fully scanned — raise `max_events`.\n- Box's `admin_logs` API has no `created_by` query parameter, so this is a\n  client-side filter (`fetch_admin_events(created_by_logins=...)`).\n\n### One folder's contents (`list_folder_items`)\n\nAn `ls`, not a `cat`. Written for a help desk answering a submitted enquiry whose\nattachments land in a Box folder: instead of a human going to find that folder,\nthe answer names the attachments and links straight to them. File content is\nnever read, and no shared link is ever created — an existing one is reported\nbecause it is an exposure finding, not a convenience.\n\n**Who uploaded an item is not where you would look for it.** For an upload made\nthrough a File Request, Box records no user at all: `created_by` and\n`modified_by` both read *\"Anonymous User\"*, and `owned_by` is the application's\nown service account — identical on every row. The only field carrying the\nsubmitter is `uploader_display_name`, and despite its name the value observed in\npractice was an email address. It is therefore matched as an **opaque string**\n(exact, case-insensitive) and never parsed or validated as an address. For a file\nuploaded by a signed-in user the reverse holds, so `created_by` is the fallback.\n\nOrdering and time bounds are computed here rather than by Box:\n\n- Box documents `sort` as the **second** sort attribute — items order by type\n  first, so a subfolder precedes every file regardless of date. Measured against\n  a real folder, `sort=date` also matched neither `created_at` nor `modified_at`\n  order, so it cannot honestly be presented as \"newest\".\n- `since` / `until` are compared as **instants, not text**. Box stamps items in\n  its own UTC offset while a caller asks in theirs, so a lexicographic comparison\n  is wrong by that difference at every date boundary and silent about it. Both\n  bounds must carry an offset; a bare date is refused rather than guessed.\n\n`limit` bounds what is RETURNED, not what is searched — a full page is fetched\nfirst, so an uploader's item is found even when it is not among the newest.\nTruncation is disclosed twice over, because they are different truths: `returned`\nvs `matched` is the caller's own limit, while `capped` means the folder holds\nmore than one page and a miss is inconclusive rather than negative.\n\n### Per-account lookup (`get_user`)\n\nEvery other tool reads the event stream or walks folders, so an account with no\nrecent activity cannot be asked about at all. `get_user` answers directly —\n\"is this account disabled, and is its quota full?\" — in one request:\n\n```\nget_user(login=\"someone@example.com\")\n```\n\n`login` is the account's full Box login (an email address), matched **exactly\nand case-insensitively**. That matching is the point, not an implementation\ndetail: Box's underlying `filter_term` is a **prefix search over display name\nand login**, so the endpoint readily returns a colleague whose name starts with\nthe same letters. Only an exact login match lands in `user`; everything else is\ncounted in `other_prefix_hits` and never identified. A term that is not email-shaped is\nrefused before the request is made — `filter_term` has no minimum length, so a\none-character term would otherwise return a page of real accounts.\n\nOne drift it cannot find: the same person under a second login at another domain.\n`filter_term` prefix-matches the whole term, so `alice@old.example` can never return\n`alice@new.example`; that would need a search on the local part alone, which is the\ndirectory-wide prefix search this tool refuses by design.\n\n| Field | Meaning |\n|---|---|\n| `found` | The only field that says whether the account exists. `false` is a normal answer, not an error |\n| `user` | The account when `found`, else `null`: `status`, `role`, `enterprise`, `space_used` / `space_amount`, `created_at`, `modified_at` |\n| `other_prefix_hits` | Count of further prefix matches. A count only: those are different accounts and are deliberately not identified |\n| `capped` | The search was truncated, so `found: false` is inconclusive rather than negative |\n| `search_hits`, `note` | How many entries came back, and a plain-language reading |\n\n> [!NOTE]\n> In `oauth` mode this endpoint's requirement **is verified end-to-end**: the\n> app must hold the **\"Manage users\" application scope**. Without it\n> `/2.0/users` answers 403 even when the authorising user is a co-admin who can\n> manage users; with it, 200. Two caveats: the effective permission is still\n> capped by the authorising user's own role, and a scope added in the Developer\n> Console does **not** reach tokens minted from an existing refresh token — the\n> app must be re-authorised interactively (`boxadm-mcp auth`) before the new\n> scope takes effect. Under `ccg` the endpoint remains unverified. A permission\n> failure returns `likely_cause` saying all of this rather than a bare HTTP\n> status.\n\n## Usage\n\n### Claude Code (plugin)\n\nThis repository doubles as a single-plugin marketplace, so Claude Code can install\nthe server for you:\n\n```\n/plugin marketplace add shigechika/boxadm-mcp\n/plugin install boxadm-mcp@boxadm-mcp\n```\n\nThe plugin launches `uvx boxadm-mcp` and reads the same environment variables\ndescribed in [Configuration](#configuration); export `BOX_CLIENT_ID`,\n`BOX_CLIENT_SECRET`, `BOX_ENTERPRISE_ID` (ccg mode), and `BOX_ALLOWED_DOMAINS`\nbefore starting Claude Code. The plugin ships with `BOX_AUTH_MODE=ccg` by default —\nswitch to `oauth` only after running `boxadm-mcp auth` once yourself, since the\nplugin cannot provision that browser step or the resulting token cache file for\nyou.\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`:\n\n```json\n{\n  \"mcpServers\": {\n    \"boxadm-mcp\": {\n      \"type\": \"stdio\",\n      \"command\": \"boxadm-mcp\",\n      \"env\": {\n        \"BOX_AUTH_MODE\": \"oauth\",\n        \"BOX_CLIENT_ID\": \"${BOX_CLIENT_ID:-}\",\n        \"BOX_CLIENT_SECRET\": \"${BOX_CLIENT_SECRET:-}\",\n        \"BOX_ALLOWED_DOMAINS\": \"example.com\"\n      }\n    }\n  }\n}\n```\n\n### CLI Options\n\n```bash\nboxadm-mcp auth       # OAuth first-time login (opens a browser)\nboxadm-mcp --version  # Print version and exit\nboxadm-mcp            # Start MCP server (STDIO, default)\n```\n\n## Development\n\n```bash\ngit clone https://github.com/shigechika/boxadm-mcp.git\ncd boxadm-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 respx ruff\n.venv/bin/pytest -v\n.venv/bin/ruff check .\n```\n\nTests never touch Box — `respx` mocks the CCG/OAuth token endpoint and the\n`admin_logs`/enumeration APIs.\n\n### Live smoke test\n\nThat isolation is the point of the unit tests, and also their limit: they\ncannot tell you that a tool has stopped returning real data.\n`scripts/smoke_test.py` runs **every registered tool** against the configured\nenterprise and fails on empty, malformed or error answers:\n\n```bash\n# needs the same BOX_* environment variables as the server\nuv run python scripts/smoke_test.py\nuv run python scripts/smoke_test.py --only shared_links --traceback\n```\n\n- **Read-only.** Every tool here reads; nothing in Box is changed. A future\n  tool that writes must be listed as state-changing and skipped, and a test\n  enforces that.\n- **No payloads in the report.** Tool names, statuses and row counts only;\n  server-authored error text is redacted too, since Box errors quote the\n  account or item they were asked about.\n- **Bounded.** These tools page the event stream and walk the folder tree, so\n  each probe passes explicit small caps instead of the interactive defaults\n  (5000 events, 150 folders) — enforced by a test that finds the bounding\n  parameters from the source.\n- **Nothing enterprise-specific in the specs.** A test bans address shapes\n  (login, URL, hostname, IPv4, IPv6) and the parameters that carry an account\n  name, because this repository is public. Two literals identify nobody and are\n  allowed: folder id `0`, the root folder in every enterprise, and the made-up\n  term `get_user` is probed with — an account that cannot exist, so the probe\n  asserts the not-found path rather than naming a real person.\n- An empty answer passes: no public links and no external collaborators is the\n  desired state, so probes assert the accounting envelope (`count`,\n  `folders_scanned`, `window_hours`) rather than a row count.\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 Box knowledge: it is\n  kept identical across the servers that share it, so fix engine bugs once and\n  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 `boxadm_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## Governance\n\nBecause this surfaces what users share, run it as **authorized information-security\nmonitoring** with a clear purpose, a defined set of viewers, and a retention\npolicy. Most external sharing is legitimate (collaborators, vendors), so treat\nfindings as a **risk ranking**, not an alert queue — build an allowlist of\nknown-OK sharers over time.\n\n## License\n\nMIT\n",
  "bytes": 18334,
  "sha": "a85d7792809e5d022a847faaa0a264360c5668f55d2aedbfef2e9872951ada51",
  "repo_slug": "shigechika/boxadm-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_shigechika_boxadm_mcp_f44be421/readme"
}