# Agents & MCP

<!-- GENERATED SNIPPETS: every install block below is printed by openpush/server/scripts/mcp_install_links.py — do not hand-edit them. -->
<!-- Regenerate:  cd ../openpush/server && python3 scripts/mcp_install_links.py -->
<!-- Verify:      python3 scripts/mcp_install_links.py --check ../../openpush-docs/pages/guides/mcp.md   (or `npm run check:mcp-guide` from this repo) -->
<!-- That check is LOCAL-ONLY: the docs repo cannot see the server repo in CI, so it is not part of `npm run check`. Run it after changing either side. -->
<!-- Multi-line HTML comments break the Zudoku 0.86 build (it fails with a misleading OpenAPI publish-conflict error), which is why these are one per line. -->

OpenPush speaks [MCP](https://modelcontextprotocol.io), so a coding agent can read your push reports, work out why one player never got a notification, and draft a campaign — using the same guards, the same audience maths and the same audit trail as the console.

See also [Codebase scan](/guides/codebase-scan), which turns SDK signals in your app repository into a reviewed manifest and evidence-backed drafts through MCP.

It is **push-only** (no email, no SMS), there are **no delete tools** of any kind, and **every write is two-step**: the agent prepares an action and shows you a preview, and nothing happens until you say send. The one exception is a test push, which goes only to the handsets you registered as test devices in the console.

There are two ways to connect. Most clients do one or the other; Claude Code does both:

- **OAuth** — Claude Code, Claude.ai, Claude Desktop and ChatGPT take a URL and nothing else. You paste `https://app.openpush.ai/mcp`, sign in to OpenPush in a browser, pick the app and the permissions, and that is the whole setup. No key to copy, and you can end the connection from the console at any time.
- **A REST key in a header** — for repos, CI and headless agents (Claude Code with a shared `.mcp.json`, Cursor, VS Code, Windsurf). The credential is an environment variable rather than a browser session, which is what a machine with no browser in front of it needs.

## Prerequisites

Only for the header path. If you are signing in through OAuth — Claude Code included — you need none of this.

- **A REST key for the app.** Console → Settings → Keys. **Create a read-only key first** — every report, profile and delivery trace works with it, and it cannot send, edit or prepare anything. Move to a full-scope key only when you want the agent to draft sends, segments, templates or tag changes.
- **Your app slug** (e.g. `runner-club`), from the console URL or the app switcher. A per-app key already knows its app, so you rarely have to type it.
- The endpoint: `https://app.openpush.ai/mcp`. `POST` only, `Authorization: Bearer <your REST key>`.

Keep the key in an environment variable rather than in a file you commit:

```bash
export OPENPUSH_API_KEY="op_v2_app_…"
```

## Connect

### Claude.ai and Claude Desktop

Settings → Connectors → **Add custom connector**, then:

- **URL:** `https://app.openpush.ai/mcp`
- **Client ID / Client secret:** leave both blank. OpenPush registers your client for you, and there is no secret to hold.

The first time the agent calls a tool, a browser window opens on OpenPush. Sign in if you are not already, then **pick the app** the agent may use and **choose its permissions**:

- **Read** — every report, profile and delivery trace. This is the one to start with.
- **Read + prepare** — also lets the agent draft sends, segments, templates, tag changes and journey drafts. Each is still two-step: the agent prepares, you confirm. Granting it needs the manager or admin role on the app you picked.

One connection covers one app and acts as you. Add a second connection for a second app.

### ChatGPT and Codex

The same URL — `https://app.openpush.ai/mcp` — wherever your client accepts a remote MCP server. Leave any client id and secret fields blank; the browser consent is the same as above, and the agent gets the app and the permissions you chose there.

### Claude Code

**Sign in (recommended).** Add the server with no key at all:

```bash
claude mcp add --transport http --scope user openpush https://app.openpush.ai/mcp
```

Then authenticate once:

1. Run `claude`, and type `/mcp`.
2. Pick `openpush`, then choose **Authenticate**.
3. **Leave that prompt open.** It is what listens for the browser coming back; close it and the sign-in has nowhere to land.
4. A browser opens on OpenPush. Sign in, **pick the app**, and choose its permissions — **Read** to start with, or **Prepare changes** as well, which needs the manager or admin role on that app.
5. Click **Allow**. The tab hands you back to Claude Code, and `openpush` shows as connected with 41 tools.

To end the connection later: Console → your app → Settings → **Connected agents** → Revoke.

**Shared or headless setups.** A config you check in, a CI job, or any agent with no browser in front of it wants the header key instead:

```bash
claude mcp add --transport http openpush https://app.openpush.ai/mcp --header "Authorization: Bearer ${OPENPUSH_API_KEY}"
```

To share it with a repo instead, commit a `.mcp.json` at the project root — the `${OPENPUSH_API_KEY}` reference is expanded from the environment, so the file holds no secret:

```json
{
  "mcpServers": {
    "openpush": {
      "type": "http",
      "url": "https://app.openpush.ai/mcp",
      "headers": {
        "Authorization": "Bearer ${OPENPUSH_API_KEY}"
      }
    }
  }
}
```

### Cursor

`.cursor/mcp.json` in the project (or `~/.cursor/mcp.json` for every project). Cursor resolves `${env:NAME}` inside `headers`, so the key stays in your environment:

```json
{
  "mcpServers": {
    "openpush": {
      "url": "https://app.openpush.ai/mcp",
      "headers": {
        "Authorization": "Bearer ${env:OPENPUSH_API_KEY}"
      }
    }
  }
}
```

One-click install — paste this into your browser's address bar with Cursor installed:

```text
cursor://anysphere.cursor-deeplink/mcp/install?name=openpush&config=ewogICJ1cmwiOiAiaHR0cHM6Ly9hcHAub3BlbnB1c2guYWkvbWNwIiwKICAiaGVhZGVycyI6IHsKICAgICJBdXRob3JpemF0aW9uIjogIkJlYXJlciAke2VudjpPUEVOUFVTSF9BUElfS0VZfSIKICB9Cn0%3D
```

### VS Code

`.vscode/mcp.json`. VS Code does not interpolate environment variables into MCP headers, so it prompts once and stores the answer as a secret:

```json
{
  "servers": {
    "openpush": {
      "type": "http",
      "url": "https://app.openpush.ai/mcp",
      "headers": {
        "Authorization": "Bearer ${input:openpush-key}"
      }
    }
  },
  "inputs": [
    {
      "type": "promptString",
      "id": "openpush-key",
      "description": "OpenPush REST key",
      "password": true
    }
  ]
}
```

### Windsurf

`~/.codeium/windsurf/mcp_config.json`. Windsurf spells the URL `serverUrl` and interpolates `${env:NAME}`:

```json
{
  "mcpServers": {
    "openpush": {
      "serverUrl": "https://app.openpush.ai/mcp",
      "headers": {
        "Authorization": "Bearer ${env:OPENPUSH_API_KEY}"
      }
    }
  }
}
```

### Any HTTP MCP client

Point it at `https://app.openpush.ai/mcp` with streamable HTTP. Send the key as `Authorization: Bearer <key>` (or `X-OP-API-Key: <key>`, if that suits your client better) — or send nothing and let the client do OAuth: an unauthenticated request answers `401` with a `WWW-Authenticate` header naming `/.well-known/oauth-protected-resource/mcp`, which is all a spec-compliant client needs to find the flow. The authorization code grant is PKCE-`S256`-only, clients are public (no secret), and the `resource` parameter must be `https://app.openpush.ai/mcp` on both the authorize and token calls. The endpoint is stateless — no session to resume — and answers `POST` only.

## First calls

Ask the agent to run these two before anything else:

- **`openpush_health`** — is the server up, does this app have working FCM/APNs credentials, how many devices are subscribed, and what scope is your key. If platform credentials are missing, nothing else will make sense.
- **`openpush_reference`** — the map: what the server can and cannot do, the vocabulary the reports use, and which tool belongs to which task. Reading it once saves an agent from guessing at tools that do not exist.

A good opening prompt is simply: *"Call openpush_health and openpush_reference, then tell me what you can see."*

## What the agent can do

41 tools, grouped the way you would group the work:

| Task | Tools |
|---|---|
| **Orientation** | `openpush_health`, `openpush_reference`, `openpush_list_apps`, `openpush_get_settings` |
| **Campaign reports** | `openpush_list_messages`, `openpush_get_message_report`, `openpush_list_message_deliveries`, `openpush_get_analytics_series` |
| **Audience** | `openpush_list_segments`, `openpush_preview_audience`, `openpush_search_users`, `openpush_search_subscriptions`, `openpush_list_test_devices` |
| **Diagnostics** | `openpush_get_user`, `openpush_get_subscription`, `openpush_trace_delivery`, `openpush_list_custom_events`, `openpush_get_event_catalogue`, `openpush_list_audit_entries` (full-scope key) |
| **Content** | `openpush_list_templates`, `openpush_get_template`, `openpush_list_dynamic_content`, `openpush_get_dynamic_content`, `openpush_render_preview` |
| **Journeys** | `openpush_list_journeys`, `openpush_get_journey`, `openpush_get_journey_stats` |
| **In-app messages** | `openpush_list_in_app_messages`, `openpush_get_in_app_message` |
| **Imports** | `openpush_list_imports`, `openpush_get_import` |
| **Two-step writes** | `openpush_prepare_message`, `openpush_prepare_cancel_message`, `openpush_prepare_segment`, `openpush_prepare_template`, `openpush_prepare_user_tags`, `openpush_prepare_journey_draft`, `openpush_confirm_action`, `openpush_discard_action`, `openpush_list_pending_actions` |
| **Test send** | `openpush_send_test` |

There are also three ready-made workflows your client can offer as prompts: **debug delivery** (why didn't this user get it), **campaign brief** (goal in, prepared draft out), and **weekly review** (last N days, with honest metric names).

## How writes work

Every state change is two calls, with you in the middle.

1. **Prepare.** `openpush_prepare_message` (or `_segment`, `_template`, `_user_tags`, `_cancel_message`, `_journey_draft`) validates the request exactly as the REST API would, runs the real audience and guard preview — audience, capped, held for quiet hours, timed for best-hour delivery, remaining, per-platform, per-language — renders your Liquid against a real sampled device, and returns that preview with a `confirm_token`. **Nothing has been sent, saved or changed.**
2. **You look at the preview.** This is the point of the design. The numbers are the same ones the console's Review-and-Send screen shows.
3. **Confirm or discard.** `openpush_confirm_action` with the token executes it, once. `openpush_discard_action` closes it so it can never be confirmed by mistake.

Details worth knowing:

- **Tokens live 10 minutes** and are **single-use**. A second confirm with the same token reports "already confirmed" and does nothing.
- **A token belongs to one app.** A key scoped to another app cannot see or confirm it.
- **Idempotency.** An agent can pass an `idempotency_key` (16–128 characters) to any prepare. A retry with the same key *and the same request* replays the original action instead of creating a second one; the same key with a changed request is refused rather than silently reused.
- **`openpush_list_pending_actions`** shows what is prepared but not yet confirmed, with the preview and the time left. A read-only key can see that list — without the tokens.
- **A read-only key cannot prepare, confirm, test-send, or read the audit log at all.** The refusal says so in as many words.

The one immediate write is `openpush_send_test`, and it reaches **only devices an operator registered as test devices in the console** (a colleague's or a player's real phone, chosen deliberately); it never appears in Sent Messages. Be clear-eyed about what that bound is: a test device is a real handset belonging to a real `external_id`, so the push does arrive — what makes this safe to run without a confirmation is that the list is curated by hand in the console, not that nobody receives it. Check the list with `openpush_list_test_devices` before you let an agent use it, and note that test devices deliberately ignore quiet hours and the frequency cap. Operators who want even this behind a confirmation can set `OP_MCP_TEST_SEND=confirm` on a self-hosted instance.

## Diagnosing delivery

`openpush_trace_delivery` takes one message and one person (`external_id` or `user_id`) or one device (`subscription_id`), and answers in a closed vocabulary — no prose to interpret:

| Verdict | What actually happened |
|---|---|
| `not_in_audience` | The device does not match the message's segments or target. |
| `capped` | Suppressed by the frequency cap, a preference, or an opt-out. |
| `held_quiet_hours` | Inside the app's quiet hours; the scheduler will send it when the window opens. |
| `waiting_best_hour` | Best-hour delivery is holding it for this user's best hour. |
| `queued` / `retrying` | On its way, or being retried after a transient provider error. |
| `failed` / `dead_lettered` | The provider rejected it; the error string says why. |
| `accepted_no_receipt` | FCM or APNs took it, and no device receipt has arrived yet. |
| `received` / `confirmed` / `clicked` | The SDK got the data / the notification was displayed / the person tapped it. |
| `user_not_found`, `no_devices`, `no_sendable_device`, `message_not_sent_yet` | The question itself has no delivery behind it. |

The vocabulary is deliberate about one thing in particular: **"Provider Accepted" is not "delivered"**. FCM or APNs taking a push means they took it, not that a phone showed it. The ladder is Provider Accepted → Device Received → Confirmed Receipt → Clicked, and each rung is a different fact. An agent that calls provider acceptance "delivered" is reading the numbers wrong.

For the wider picture: `openpush_get_user` for one person's devices and recent deliveries, `openpush_get_subscription` for one device with the segments it matches right now, and `openpush_list_message_deliveries` for every targeted device on a message with `by_state` counts.

## Security

- **Key scope is enforced server-side.** A read-only key gets every read tool and no write tool — not by convention, by refusal. The one read it does not get is the audit log, whose rows name your operators: `openpush_list_audit_entries` needs a full-scope key, the same bar the console holds that log to. Give the agent a read-only key unless it needs to draft writes.
- **No secrets come back through a tool.** No key values, no platform credentials, and push tokens are always masked (first six characters, `…`, last four). There is no tool that lists keys.
- **The one unconfirmed write reaches real phones.** `openpush_send_test` sends without a prepare/confirm step, to every device on the app's test-device list — real handsets belonging to real people, put there deliberately by an operator. Treat that list as the permission it is: audit it in the console, and keep it to phones whose owners expect test pushes.
- **No deletes exist.** Users, subscriptions, segments, templates, journeys, in-app messages, keys and apps cannot be deleted through MCP at all. Neither can keys be rotated or apps created. Those stay in the console, with a human in front of them.
- **Journeys can be drafted, not activated.** Setting a journey live is console-only.
- **Every tool call is audited.** Console → Settings → Audit shows one row per call: the tool name, the key's display name (never its value), the app, and whether it succeeded. That is your record of what an agent did, and it is the same log the console and REST API write to. One quirk worth knowing if you filter it: the row's item type carries both object types (`user`, `template`, `segment`) and two-step action kinds (`send_message` for a confirmed send), so a filter has to expect both.
- **Revoking access.** For an OAuth connection: Console → Settings → **Connected agents** → Revoke. The page lists every agent that has connected to the app, which person's access each one is using, what it may do and when it last called; an admin can end any of them and the next tool call is a 401. For a header key: Console → Settings → Keys → disable. Either way there is no separate agent session to hunt down.
- **An OAuth grant is narrower than a key, not wider.** It covers **one app**, acts as **one person**, and carries only what that person could do themselves — a viewer cannot grant the prepare permission at all. It expires an hour after issue (the client refreshes silently) and lapses for good after 30 days without use. Nothing is stored in a form that could be replayed: OpenPush keeps only hashes of the tokens, so a copy of its database is not a set of working credentials.
- See [Security and limits](security-and-limits.md) for how the key kinds work in general.

## Limits

- **List tools page.** Most default to 25 rows and accept up to 200; a few whole-collection reads (segments, templates, test devices, apps) simply return everything the app has. Cursor-based tools return a `next_cursor` to continue.
- **Large results are shortened, not silently trimmed.** A payload over 25,000 characters comes back with `truncated: true`, a count of what was omitted, and a note telling the agent to page or filter. The fields that identify *what* the result is about are never dropped.
- **The REST API's own limits still apply** — they are the same code path. See [Security and limits](security-and-limits.md).
- **The endpoint is stateless.** Nothing is remembered between calls, which is also why nothing leaks between two clients sharing a key.

## Not yet

- **In-app message and Live Activity writes.** Both are readable; neither is editable through MCP.
- **Any delete, key management, app creation or platform credential change.** Not planned.

The header-key path is not going away, and is not a legacy: a repo-committed `.mcp.json`, a CI job, or any agent running with no browser in front of it wants a credential in an environment variable, not a sign-in. OAuth is for the person at a keyboard, and for the clients that cannot send a header at all.

## Troubleshooting

| Symptom | Cause | Fix |
|---|---|---|
| `401` / `bad credential` | Missing, mistyped, or disabled key | Check the header is `Authorization: Bearer <key>` and that the key is still enabled in Console → Settings → Keys. A key for another app also reads as bad. |
| `421 Misdirected Request` | The `Host` header is not on the server's allowlist — self-hosted instances only | Set `OP_MCP_ALLOWED_HOSTS` to your public hostname (comma-separated; a `*` suffix allows any port). |
| "this credential is read-only" | A read-only key tried a `prepare_*`, `confirm`, `discard`, test send, or the audit-log read | Expected. Use a full-scope key if the agent should draft writes. |
| "this credential is the global operator key: pass app_id" | The cross-app operator key was used | Pass `app_id`, or use a per-app key. A cross-app credential never guesses which app you meant. |
| `405 Method Not Allowed` | A `GET` reached `/mcp` | The endpoint is `POST` only. Check your client's transport is streamable HTTP, not SSE. |
| "the sample app sends from the console only" | The shared sample app was targeted | Expected: one key serves every tenant there. Use your own app. |
| A tool the agent expected does not exist | It probably invented it | Have it call `openpush_reference` — the list of what is deliberately absent is part of the answer. |
| The browser consent says "granting write requires the manager or admin role" | You are a viewer on the app you picked | Grant **Read** only, pick an app where you are a manager, or ask an admin. The permission is never quietly reduced — it is refused, so you know what you got. |
| The consent screen says "redirect_uri is not registered for this client" | The client asked to be answered at a URL it never registered | Expected, and it is shown as a page rather than a redirect on purpose. Re-add the connector in your client so it registers cleanly. |
| An OAuth connection that worked stops with `401` | It was revoked, or unused for 30 days | Check Console → Settings → Connected agents. Re-add the connector to start a fresh consent. |
| Clicking **Allow** returns to Claude Code but it still says "Needs authentication" / the callback never arrives | The `/mcp` **Authenticate** prompt was closed or timed out before **Allow** was clicked — nothing was left listening for the browser | Run `/mcp` → **Authenticate** again, and complete the consent while the prompt is still open. |
