boxadm-mcp
English | 日本語
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.
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)
- Developer Console → Create Platform App → Custom App → User Authentication (OAuth 2.0)
- Redirect URI:
http://localhost:8787/callback - 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 (requires re-consent) - Enable the app in the Admin Console (unpublished apps are disabled by default under most tenant policies)
- Note the Client ID / Client Secret
- First login: set
BOX_AUTH_MODE=oauthetc., then runboxadm-mcp auth→ authorize in the browser → a token cache is written to~/.config/boxadm-mcp/token.json(chmod 600)
Setup
# uv
uv pip install boxadm-mcp
# pip
pip install boxadm-mcp
Or from source:
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 ofdaily_brief) read the enterprise-wide events stream. Hittingmax_eventssetscapped: 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_depthlimits (surfaced viacapped). 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 retries429(honoringRetry-After) and transient5xxwith 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 persistent403) is counted infetch_errors: coverage is complete only whencappedis false andfetch_errorsis 0. - Enumeration tools share a short-TTL scan memo across calls;
public_shared_linksskips collaboration calls entirely (optimization). get_userreads the enterprise user directory instead — one request, no paging, and structurally not an enumerator (it answers about the login you pass and nothing else). Itscappedflag discloses a truncated search, so afound: falsefrom 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 ofevents_scanned(no running total is kept; usecappedto judge coverage).capped: truemeans the window wasn't fully scanned — raisemax_events. - Box's
admin_logsAPI has nocreated_byquery 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
sortas the second sort attribute — items order by type first, so a subfolder precedes every file regardless of date. Measured against a real folder,sort=datealso matched neithercreated_atnormodified_atorder, so it cannot honestly be presented as "newest". since/untilare 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] Whether
/2.0/usersis reachable has not been verified end-to-end in either auth mode, so this README deliberately claims no scope requirement for it. Underoauththe effective permission is the authorising user's. A permission failure returnslikely_causenaming the two things to check — that user's Box role, and the app's Application Scopes — 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; 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 system-wide if the plugin fails to start.
Claude Code (manual)
Add to .mcp.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
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
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:
# 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 termget_useris 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.pyis 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.
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 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 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.
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
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file boxadm_mcp-0.9.0.tar.gz.
File metadata
- Download URL: boxadm_mcp-0.9.0.tar.gz
- Upload date:
- Size: 85.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
db5c789408d616c6028f3754d6f986b3a26730512523a27c1c060212d4d2ab23
|
|
| MD5 |
6413c2866e56940169fb7031a6e512ec
|
|
| BLAKE2b-256 |
5df4e20630c4baaa8dc700946850760a65bb52006e6f8989a3640db0a8135bf5
|
Provenance
The following attestation bundles were made for boxadm_mcp-0.9.0.tar.gz:
Publisher:
release.yml on shigechika/boxadm-mcp
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
boxadm_mcp-0.9.0.tar.gz -
Subject digest:
db5c789408d616c6028f3754d6f986b3a26730512523a27c1c060212d4d2ab23 - Sigstore transparency entry: 2475630878
- Sigstore integration time:
-
Permalink:
shigechika/boxadm-mcp@943cfee5a156aa22f1a13e9074d14736d9b77c75 -
Branch / Tag:
refs/tags/v0.9.0 - Owner: https://github.com/shigechika
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@943cfee5a156aa22f1a13e9074d14736d9b77c75 -
Trigger Event:
release
-
Statement type:
File details
Details for the file boxadm_mcp-0.9.0-py3-none-any.whl.
File metadata
- Download URL: boxadm_mcp-0.9.0-py3-none-any.whl
- Upload date:
- Size: 46.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2b87170df617af990b7c2941a73c7e3372e172e43e0498c1c50f8a5e5e8748e9
|
|
| MD5 |
399b917fee4723631d6dbf563114b289
|
|
| BLAKE2b-256 |
6412ee60bd84acb6f3bc0ea20a9890ca58fdc29176ff4aca8c14f287b66b6702
|
Provenance
The following attestation bundles were made for boxadm_mcp-0.9.0-py3-none-any.whl:
Publisher:
release.yml on shigechika/boxadm-mcp
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
boxadm_mcp-0.9.0-py3-none-any.whl -
Subject digest:
2b87170df617af990b7c2941a73c7e3372e172e43e0498c1c50f8a5e5e8748e9 - Sigstore transparency entry: 2475630918
- Sigstore integration time:
-
Permalink:
shigechika/boxadm-mcp@943cfee5a156aa22f1a13e9074d14736d9b77c75 -
Branch / Tag:
refs/tags/v0.9.0 - Owner: https://github.com/shigechika
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@943cfee5a156aa22f1a13e9074d14736d9b77c75 -
Trigger Event:
release
-
Statement type: