Agents & MCP
OpenPush speaks MCP, 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, 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.POSTonly,Authorization: Bearer <your REST key>.
Keep the key in an environment variable rather than in a file you commit:
Code
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:
Code
Then authenticate once:
- Run
claude, and type/mcp. - Pick
openpush, then choose Authenticate. - Leave that prompt open. It is what listens for the browser coming back; close it and the sign-in has nowhere to land.
- 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.
- Click Allow. The tab hands you back to Claude Code, and
openpushshows 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:
Code
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:
Code
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:
Code
One-click install — paste this into your browser's address bar with Cursor installed:
Code
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:
Code
Windsurf
~/.codeium/windsurf/mcp_config.json. Windsurf spells the URL serverUrl and interpolates ${env:NAME}:
Code
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.
- 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 aconfirm_token. Nothing has been sent, saved or changed. - 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.
- Confirm or discard.
openpush_confirm_actionwith the token executes it, once.openpush_discard_actioncloses 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_actionsshows 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_entriesneeds 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_testsends 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_messagefor 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 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_cursorto 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.
- 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. |