Metadata-Version: 2.4
Name: boxadm-mcp
Version: 0.9.1
Summary: MCP server for Box admin-log analytics — external-sharing visibility, read-only
Author: AIKAWA Shigechika
License-Expression: MIT
Project-URL: Homepage, https://github.com/shigechika/boxadm-mcp
Project-URL: Repository, https://github.com/shigechika/boxadm-mcp
Project-URL: Issues, https://github.com/shigechika/boxadm-mcp/issues
Keywords: box,mcp,model-context-protocol,security,audit
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: System Administrators
Classifier: Operating System :: POSIX
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: mcp<2,>=1.2
Requires-Dist: httpx>=0.27
Dynamic: license-file

<!-- mcp-name: io.github.shigechika/boxadm-mcp -->

# boxadm-mcp

English | [日本語](README.ja.md)

MCP (Model Context Protocol) server that surfaces **external file flow** from
a Box admin's point of view. It reads Box's enterprise event log
(`admin_logs`) to highlight "who shares a lot with the outside" and "which
files get accessed from outside" — an early-warning signal for leakage, not a
general-purpose file browser.

Documentation: <https://shigechika.github.io/boxadm-mcp/>

**Read-only**: it never revokes shares, deletes files, or otherwise mutates
anything — it only surfaces risk. This is a different tool from a
general-purpose Box file MCP (the official Box MCP, or the claude.ai Box
connector): those operate on a user's own files and cannot see enterprise
events, which is exactly what this server is for.

Named after the admin-console viewpoint (`boxadm` = Box admin), sibling of
[`gwsadm-mcp`](https://github.com/shigechika/gwsadm-mcp).

## Features

| Tool | Category | Description |
|------|------|-------------|
| `health_check` | — | version + auth_mode + Box auth + `admin_logs` scope probe + configured domain allowlist. Reports `needs-login` when not yet authenticated (OAuth mode) |
| `recent_admin_events` | Diagnostic | Raw recent enterprise events (for checking event types/fields). Supports manual pagination via `stream_position` |
| `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 |
| `external_collaborators` | Exposure (enumeration) | Lists external collaborators (outside-org login or external invite email) |
| `public_shared_links` | Exposure (enumeration) | Lists items shared with an `open` (anyone-with-the-link) share link |
| `top_external_sharers` | Exposure (enumeration) | Ranks internal owners by external exposure (external collabs + public links) |
| `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 |
| `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 |
| `daily_brief` | Combined | Morning summary combining access (events) and exposure (enumeration) |

## Auth model

Two modes, selected via `BOX_AUTH_MODE`:

- `oauth` — OAuth 2.0 (user auth). An admin authorizes once in a browser; the
  refresh token keeps it running unattended after that.
- `ccg` — Client Credentials Grant (server-to-server). Simpler to run
  unattended if your Box tenant has an available server-authentication app
  slot.

`admin_logs` (enterprise events) is readable in **either mode**, provided the
authorizing/impersonated user is an admin and the app has the **Manage
enterprise properties** scope.

### OAuth setup (one-time, by a Box admin)

1. Developer Console → Create Platform App → **Custom App → User
   Authentication (OAuth 2.0)**
2. **Redirect URI**: `http://localhost:8787/callback`
3. **Application Scopes**: check **Manage enterprise properties** (required
   for `admin_logs`). Add **Read all files and folders** too if you also want
   collaboration/share-link enumeration, and **Manage users** if you want the
   `get_user` lookup (each scope change requires re-consent via
   `boxadm-mcp auth`)
4. Enable the app in the Admin Console (unpublished apps are disabled by
   default under most tenant policies)
5. Note the **Client ID / Client Secret**
6. First login: set `BOX_AUTH_MODE=oauth` etc., then run **`boxadm-mcp
   auth`** → authorize in the browser → a token cache is written to
   `~/.config/boxadm-mcp/token.json` (chmod 600)

## Setup

```bash
# uv
uv pip install boxadm-mcp

# pip
pip install boxadm-mcp
```

Or from source:

```bash
git clone https://github.com/shigechika/boxadm-mcp.git
cd boxadm-mcp

# uv
uv sync

# pip
pip install -e .
```

## Configuration

| Variable | Required | Description |
|---|---|---|
| `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 |
| `BOX_CLIENT_ID` | ✓ | App Client ID |
| `BOX_CLIENT_SECRET` | ✓ | App Client Secret |
| `BOX_ENTERPRISE_ID` | ccg mode | Enterprise ID (CCG subject; not needed for oauth) |
| `BOX_OAUTH_REDIRECT_URI` | | oauth redirect. Default `http://localhost:8787/callback` |
| `BOX_TOKEN_CACHE` | | oauth token cache path. Default `~/.config/boxadm-mcp/token.json` |
| `BOX_API_BASE` | | Default `https://api.box.com` |
| `BOX_SCAN_CONCURRENCY` | | Parallel per-folder lookups in the enumeration scan. Default `8`, clamped `1`–`32` |
| `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 |
| `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 |
| `BOX_ALLOWED_DOMAINS` | ✓ | Internal email domains (comma-separated). No default — every address counts as external until you set this |

Keep secrets out of `.mcp.json` (e.g. in a local env file sourced before
launch); `.mcp.json` itself can reference `${BOX_CLIENT_ID}`-style variables
and be safely committed.

### Scope and limits

- **Access tools** (`external_access_events`, and the access half of
  `daily_brief`) read the **enterprise-wide** events stream. Hitting
  `max_events` sets `capped: true` (oldest-first scan).
- **Exposure (enumeration) tools** only see folders visible to the
  co-admin account (not a guaranteed 100% of the enterprise), plus
  `max_folders`/`max_depth` limits (surfaced via `capped`). Requires the
  **Read all files and folders** scope.
- The scan fans its per-folder lookups out concurrently
  (`BOX_SCAN_CONCURRENCY`), since Box has no enterprise-wide collaboration
  listing — this widens how many folders finish inside a tool-call timeout,
  but coverage is still bounded by the caps. The read path retries `429`
  (honoring `Retry-After`) and transient `5xx` with jittered backoff, so a
  passing throttle recovers instead of degrading coverage; a folder dropped by
  a per-folder API error that outlasts those retries (e.g. a persistent `403`)
  is counted in `fetch_errors`: coverage is complete only when `capped` is
  false **and** `fetch_errors` is 0.
- Enumeration tools share a short-TTL scan memo across calls;
  `public_shared_links` skips collaboration calls entirely (optimization).
- **`get_user`** reads the enterprise **user directory** instead — one request,
  no paging, and structurally not an enumerator (it answers about the login you pass and nothing
  else). Its `capped` flag discloses a truncated search, so a `found: false`
  from a truncated result reads as inconclusive rather than negative.

### DLP tracing (reverse-lookup by accessor)

To answer "what did this external account download": pass
`created_by_logins` (comma-separated logins) to `external_access_events`. It
keeps only that accessor's events and returns per-file detail
(`matched_events`: item id/name, owner, size in bytes+GB, timestamp,
event_type, whether it was via a share link).

```
external_access_events(since_hours=26, created_by_logins="someone@example.com")
```

- Since the accessor could appear anywhere in the window, a filtered call
  auto-extends the scan cap to **up to 50,000 events** (oldest-first) — but
  only matching events are kept, so memory stays bounded.
- In this mode the response carries `events_matched` (match count) instead of
  `events_scanned` (no running total is kept; use `capped` to judge coverage).
  `capped: true` means the window wasn't fully scanned — raise `max_events`.
- Box's `admin_logs` API has no `created_by` query parameter, so this is a
  client-side filter (`fetch_admin_events(created_by_logins=...)`).

### One folder's contents (`list_folder_items`)

An `ls`, not a `cat`. Written for a help desk answering a submitted enquiry whose
attachments land in a Box folder: instead of a human going to find that folder,
the answer names the attachments and links straight to them. File content is
never read, and no shared link is ever created — an existing one is reported
because it is an exposure finding, not a convenience.

**Who uploaded an item is not where you would look for it.** For an upload made
through a File Request, Box records no user at all: `created_by` and
`modified_by` both read *"Anonymous User"*, and `owned_by` is the application's
own service account — identical on every row. The only field carrying the
submitter is `uploader_display_name`, and despite its name the value observed in
practice was an email address. It is therefore matched as an **opaque string**
(exact, case-insensitive) and never parsed or validated as an address. For a file
uploaded by a signed-in user the reverse holds, so `created_by` is the fallback.

Ordering and time bounds are computed here rather than by Box:

- Box documents `sort` as the **second** sort attribute — items order by type
  first, so a subfolder precedes every file regardless of date. Measured against
  a real folder, `sort=date` also matched neither `created_at` nor `modified_at`
  order, so it cannot honestly be presented as "newest".
- `since` / `until` are compared as **instants, not text**. Box stamps items in
  its own UTC offset while a caller asks in theirs, so a lexicographic comparison
  is wrong by that difference at every date boundary and silent about it. Both
  bounds must carry an offset; a bare date is refused rather than guessed.

`limit` bounds what is RETURNED, not what is searched — a full page is fetched
first, so an uploader's item is found even when it is not among the newest.
Truncation is disclosed twice over, because they are different truths: `returned`
vs `matched` is the caller's own limit, while `capped` means the folder holds
more than one page and a miss is inconclusive rather than negative.

### Per-account lookup (`get_user`)

Every other tool reads the event stream or walks folders, so an account with no
recent activity cannot be asked about at all. `get_user` answers directly —
"is this account disabled, and is its quota full?" — in one request:

```
get_user(login="someone@example.com")
```

`login` is the account's full Box login (an email address), matched **exactly
and case-insensitively**. That matching is the point, not an implementation
detail: Box's underlying `filter_term` is a **prefix search over display name
and login**, so the endpoint readily returns a colleague whose name starts with
the same letters. Only an exact login match lands in `user`; everything else is
counted in `other_prefix_hits` and never identified. A term that is not email-shaped is
refused before the request is made — `filter_term` has no minimum length, so a
one-character term would otherwise return a page of real accounts.

One drift it cannot find: the same person under a second login at another domain.
`filter_term` prefix-matches the whole term, so `alice@old.example` can never return
`alice@new.example`; that would need a search on the local part alone, which is the
directory-wide prefix search this tool refuses by design.

| Field | Meaning |
|---|---|
| `found` | The only field that says whether the account exists. `false` is a normal answer, not an error |
| `user` | The account when `found`, else `null`: `status`, `role`, `enterprise`, `space_used` / `space_amount`, `created_at`, `modified_at` |
| `other_prefix_hits` | Count of further prefix matches. A count only: those are different accounts and are deliberately not identified |
| `capped` | The search was truncated, so `found: false` is inconclusive rather than negative |
| `search_hits`, `note` | How many entries came back, and a plain-language reading |

> [!NOTE]
> In `oauth` mode this endpoint's requirement **is verified end-to-end**: the
> app must hold the **"Manage users" application scope**. Without it
> `/2.0/users` answers 403 even when the authorising user is a co-admin who can
> manage users; with it, 200. Two caveats: the effective permission is still
> capped by the authorising user's own role, and a scope added in the Developer
> Console does **not** reach tokens minted from an existing refresh token — the
> app must be re-authorised interactively (`boxadm-mcp auth`) before the new
> scope takes effect. Under `ccg` the endpoint remains unverified. A permission
> failure returns `likely_cause` saying all of this rather than a bare HTTP
> status.

## 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/boxadm-mcp
/plugin install boxadm-mcp@boxadm-mcp
```

The plugin launches `uvx boxadm-mcp` and reads the same environment variables
described in [Configuration](#configuration); export `BOX_CLIENT_ID`,
`BOX_CLIENT_SECRET`, `BOX_ENTERPRISE_ID` (ccg mode), and `BOX_ALLOWED_DOMAINS`
before starting Claude Code. The plugin ships with `BOX_AUTH_MODE=ccg` by default —
switch to `oauth` only after running `boxadm-mcp auth` once yourself, since the
plugin cannot provision that browser step or the resulting token cache file for
you.

`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](https://docs.astral.sh/uv/) system-wide if the plugin fails to start.

### Claude Code (manual)

Add to `.mcp.json`:

```json
{
  "mcpServers": {
    "boxadm-mcp": {
      "type": "stdio",
      "command": "boxadm-mcp",
      "env": {
        "BOX_AUTH_MODE": "oauth",
        "BOX_CLIENT_ID": "${BOX_CLIENT_ID:-}",
        "BOX_CLIENT_SECRET": "${BOX_CLIENT_SECRET:-}",
        "BOX_ALLOWED_DOMAINS": "example.com"
      }
    }
  }
}
```

### CLI Options

```bash
boxadm-mcp auth       # OAuth first-time login (opens a browser)
boxadm-mcp --version  # Print version and exit
boxadm-mcp            # Start MCP server (STDIO, default)
```

## Development

```bash
git clone https://github.com/shigechika/boxadm-mcp.git
cd boxadm-mcp

# uv
uv sync --dev
uv run pytest -v
uv run ruff check .

# pip
python3 -m venv .venv
.venv/bin/pip install -e . && .venv/bin/pip install pytest respx ruff
.venv/bin/pytest -v
.venv/bin/ruff check .
```

Tests never touch Box — `respx` mocks the CCG/OAuth token endpoint and the
`admin_logs`/enumeration APIs.

### Live smoke test

That isolation is the point of the unit tests, and also their limit: they
cannot tell you that a tool has stopped returning real data.
`scripts/smoke_test.py` runs **every registered tool** against the configured
enterprise and fails on empty, malformed or error answers:

```bash
# needs the same BOX_* environment variables as the server
uv run python scripts/smoke_test.py
uv run python scripts/smoke_test.py --only shared_links --traceback
```

- **Read-only.** Every tool here reads; nothing in Box is changed. A future
  tool that writes must be listed as state-changing and skipped, and a test
  enforces that.
- **No payloads in the report.** Tool names, statuses and row counts only;
  server-authored error text is redacted too, since Box errors quote the
  account or item they were asked about.
- **Bounded.** These tools page the event stream and walk the folder tree, so
  each probe passes explicit small caps instead of the interactive defaults
  (5000 events, 150 folders) — enforced by a test that finds the bounding
  parameters from the source.
- **Nothing enterprise-specific in the specs.** A test bans address shapes
  (login, URL, hostname, IPv4, IPv6) and the parameters that carry an account
  name, because this repository is public. Two literals identify nobody and are
  allowed: folder id `0`, the root folder in every enterprise, and the made-up
  term `get_user` is probed with — an account that cannot exist, so the probe
  asserts the not-found path rather than naming a real person.
- An empty answer passes: no public links and no external collaborators is the
  desired state, so probes assert the accounting envelope (`count`,
  `folders_scanned`, `window_hours`) rather than a row count.
- 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 Box 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](https://github.com/googleapis/release-please).
Merging [Conventional Commits](https://www.conventionalcommits.org/) (`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 `boxadm_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.

## Governance

Because this surfaces what users share, run it as **authorized information-security
monitoring** with a clear purpose, a defined set of viewers, and a retention
policy. Most external sharing is legitimate (collaborators, vendors), so treat
findings as a **risk ranking**, not an alert queue — build an allowlist of
known-OK sharers over time.

## License

MIT
