meta-horizon-developer-mcp
# meta-horizon-developer-mcp
A **read-only** stdio MCP server for collecting **Analytics** from the Meta Horizon
developer dashboard (`developers.meta.com/horizon`).
For package structure and trust boundaries, see [`ARCHITECTURE.md`](ARCHITECTURE.md). For
development and change rules, see [`AGENTS.md`](AGENTS.md).
The bundled `meta-horizon-developer` CLI is for the **collection owner** only (manual
authentication and scheduled collection).
The collector opens the Analytics and Reviews pages in an authenticated browser, observes
only successful persisted GraphQL `Query` responses issued by the Dashboard itself, removes
secrets, and stores immutable JSON. It never constructs and replays requests, performs
mutations, or fetches arbitrary URLs.
## Usage
```bash
# 1. Log in manually (cookies remain in the dedicated Chromium profile).
meta-horizon-developer --app-id <APP_ID> --profile-dir .state/meta-horizon-developer/profile auth
# 2. Collect data (cron-friendly; writes one immutable JSON file).
meta-horizon-developer --app-id <APP_ID> --snapshot-dir data/raw/meta-store collect
# 3. Check status.
meta-horizon-developer --app-id <APP_ID> --snapshot-dir data/raw/meta-store status
```
CLI arguments can also be supplied through environment variables.
| Environment variable | Corresponding argument | Needed for |
|---|---|---|
| `META_HORIZON_APP_ID` | `--app-id` | everything |
| `META_HORIZON_OUTPUT_DIR` | `--snapshot-dir` | everything |
| `META_HORIZON_PROFILE_DIR` | `--profile-dir` | `auth` / `collect` / `capture-csv` |
| `META_HORIZON_CSV_DIR` | `--csv-dir` | `capture-csv` (defaults next to the profile) |
| `META_HORIZON_LOOKBACK_DAYS` | `--lookback-days` (default: 120) | `collect` |
| `PLAYWRIGHT_CHROMIUM_EXECUTABLE` | Chromium executable | browser operations |
Reading a snapshot never needs a profile: `status` and `read` resolve without one.
## MCP
Start it as a stdio MCP server. **It takes no startup arguments.** The target
application and storage location are tool arguments, so one process serves every
application and the host that launches it needs no knowledge of its callers.
```json
{
"mcpServers": {
"meta-horizon-developer": {
"command": "/path/to/bin/meta-horizon-developer-mcp"
}
}
}
```
Splitting the server per application would isolate nothing: every instance opens the
same Chromium profile and the same cookies, so the only thing separated is `app_id`,
which is not a secret.
Only four tools are exposed:
| Tool | Arguments | Returns |
|---|---|---|
| `meta_horizon_developer_status` | `app_id`, `snapshot_dir` | latest snapshot health |
| `meta_horizon_developer_collect` | `app_id`, `snapshot_dir`, `profile_dir`, `lookback_days?` | runs a collection |
| `meta_horizon_developer_latest_snapshot` | `app_id`, `snapshot_dir` | snapshot metadata (not its payload) |
| `meta_horizon_developer_read_operation` | `operation_name`, `app_id`, `snapshot_dir` | one allowed Query |
User reviews need no separate tool: read them with `meta_horizon_developer_read_operation`
using `OCDevManageApplicationReviewsTableQuery`.
**Authentication, arbitrary URLs, arbitrary operations, mutations, and Cookie/token values
are never exposed.** Authentication is available only through the CLI because it requires
human interaction.
## Invariants
- **One process per profile.** `auth` / `collect` / `capture-csv` take an exclusive lock and
fail immediately with `ProfileBusyError` if it cannot be acquired.
- **One application per directory.** Never write snapshots for a different `app_id` into
a directory that already contains snapshots (`AppIdMismatchError`).
- **Always pass `--password-store=basic`.** Without a keyring, Chromium may select another
key and appear unauthenticated because it cannot decrypt any saved cookies.
- **Separate proxy credentials.** Chromium does not use credentials embedded in a proxy URL
for CONNECT; passing them that way results in 407 and `ERR_TUNNEL_CONNECTION_FAILED`.
- **The aggregation period comes from URL `start` / `end`.** Presets allow at most 28 days,
so a custom period (default: 120 days) is passed to the Dashboard to produce a longer
daily series.
- **Fail when collection captures zero operations.** Do not leave an empty snapshot. Partial
gaps are recorded in `missing_operations` / `missing_required_operations`.
- **Finish reading a response before leaving its page.** Chromium discards the body once
the page is gone, and a handler that has not read it yet loses that operation silently.
- **Reviews are read by scrolling, and their authors are dropped before storage.** The
reviews table has no pager and no period parameter: it extends its GraphQL connection
as the inner container is scrolled, so the collector scrolls until `has_next_page` is
false. Every row also carries the reviewer's alias and account id, which name a person
and which no secret-key pattern would match, so they are removed at capture time rather
than redacted afterwards.
## Snapshot format
```json
{
"schema_version": 1,
"collected_at": "2026-08-06T01:23:45Z",
"source": "meta-horizon-dashboard",
"app_id": "…",
"operations": [{"name": "…Query", "doc_id": "…", "data": {…}, "errors": null}],
"missing_operations": ["…"],
"missing_required_operations": ["…"]
}
```
Files are named `meta-store-<UTC>.json`, mode 0600, and are **never overwritten**.
A scrolled table is many responses merged into one operation, so it is stored as a flat
list instead of a GraphQL connection. `truncated` records that the Dashboard still had
more rows when the round limit stopped the scroll — without it a short list would read as
"these are all the reviews".
```json
{"name": "OCDevManageApplicationReviewsTableQuery", "doc_id": "…", "errors": null,
"data": {"rows": [{"id": "…", "date": 1786083632, "score": 5, "title": "…",
"description": "…", "helpful_count": 1, "is_down_ranked": false,
"developer_tags": [], "app_version": "0.1.10283",
"developer_response": "…", "has_moderation_request": false}],
"truncated": false, "response_count": 20}}
```
`dashboard.json` is the contract for the Dashboard pages and allowed operations; it does
not identify an application. The page layout is shared across applications, so this
allowlist is shared as well. `pages` are opened with the analytics period; `scroll_pages`
carry no period and are read by scrolling to the end of their table.
## Secrets
- Cookies remain only in the dedicated profile; they are not written to configuration or snapshots.
- Responses are redacted before storage (authorization / cookie / token / session / email /
user_id / dtsg and similar fields).
- Review authors are dropped at capture time. Redaction matches key names, and the author
arrives under `alias` / `id`, which no pattern would catch.
- Profiles and snapshots must never be committed to Git.
## Development
```bash
uv run --project . pytest -q
uv run --project . ruff check src tests
```
The package requires Python 3.13 or newer. Runtime state is intentionally excluded from Git.
### Streamed analytics responses
Collection accepts both a single JSON response and streamed JSON documents, merging deferred data at its GraphQL path before redaction. Malformed responses remain missing operations; report completeness checks must not be relaxed.
### Incremental daily collection
Use `collect --incremental` for daily operation. Successful collected dates are reused,
including dates where the provider returned no values. Dashboard page URLs request only
new dates. Daily time series are assembled with the original snapshot; `acquisition`
records the requested range and the reused filename, SHA256 and collection time.
`collected_at` remains the time of the new acquisition; `assembled_at` records assembly.
A same-day repeat returns the existing file without opening a browser.
Undated summaries describe the new request window, not the combined history. Lifetime
cards follow the provider's scope. Reviews have no date cursor in the supported
Dashboard workflow, so they still scroll to completion. A failed new acquisition does
not fall back to the older snapshot. Legacy snapshots are adopted only when their
observed left boundary covers the requested history; otherwise a full bootstrap is needed.
TDQS
Scored across 4 tools
The status and latest_snapshot tools both report collection health, creating ambiguity about which to use. collect and read_operation are clearly distinct, but the overlap between the two status-related tools is confusing.
Tool names mix conventions: 'status' and 'latest_snapshot' are noun phrases, while 'collect' and 'read_operation' start with verbs. There is no consistent verb-first or predictable pattern, making it hard to guess the right tool.
With 4 tools, the server is tightly scoped, which is appropriate for a niche developer-oriented MCP. However, the duplicate health functionality between status and latest_snapshot suggests the count could be reduced without losing capability.
The core workflow of collecting snapshots and reading operations is covered, but there is no tool to list snapshots, retrieve a specific non-latest snapshot, or manage the snapshot lifecycle. The overlap between status and latest_snapshot also indicates the surface is not fully refined.