> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pioneeroptimisation.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Connect the PIO MCP server

> Give your coding agent live access to project status, integration health, and project setup.

The PIO MCP server lets a coding agent check its own work. After the agent writes the integration and runs the game, it calls `check_integration`. The response carries the same facts the dashboard shows: whether events arrived, which event types are missing, and the next step. The tools cover the loop from creating a project, through authoring its states, decisions, rewards and event types, to confirming that decisions flow in and events flow out. An agent can also create a model from a template, fork and archive models, and read notifications. The opt-in traffic scope adds the tools that change what live players get: going live, setting the split and automatic rollback, deciding challengers, resizing Baseline, moving users to a winner, retiring an option, resetting a broken measurement, and editing what a live model uses.

The server speaks MCP over Streamable HTTP at one endpoint:

```text theme={null}
https://app.pioneeroptimisation.com/mcp
```

Every request is a `POST` with a JSON-RPC body. There is no session to keep alive.

## Choose a credential

Two credential types work, with the same scopes. Pick by how the agent runs.

| Credential | Use it for | How it is issued |
| - | - | - |
| OAuth 2.1 | Interactive clients: Claude Code, Cursor, and any client with an MCP login flow | The client registers itself, opens a consent page, and you approve it once |
| API token | Headless runs: CI, scheduled agents, scripts | You create it in the dashboard and paste it into the client's configuration |

Scopes are the same for both:

| Scope | Grants |
| - | - |
| `mcp:read` | `whoami`, `get_project_status`, `check_integration`, `get_model_lifecycle`, `get_project_inputs`, `list_model_templates`, `list_notifications`, `get_holdout_suggestion`, `get_winner_suggestion`, `get_experiment_evidence`, `get_guardrails`, `get_forecasts` |
| `mcp:write` | `create_project`, `update_project`, `get_api_key`, `create_action_definition`, `update_action_definition`, `register_event_type`, `create_state_definition`, `update_state_definition`, `create_reward`, `update_reward`, `create_model_from_template`, `fork_model`, `archive_model`, and `mark_notification_read`, in addition to the read tools |
| `mcp:traffic` | `go_live`, `set_traffic_split`, `set_auto_rollback`, `promote_model`, `abandon_model`, `shrink_baseline`, `shift_to_winner`, `retire_choice`, and `reset_measurement`. With `mcp:write`, it also lets `update_reward` and `update_action_definition` change what a live model uses. These change what live players get, so the scope is never granted by default: tick it explicitly when you issue the credential |

A credential without `mcp:write` never sees the write tools in `tools/list`, and one without `mcp:traffic` never sees the traffic tools. Every credential is bound to one account, chosen when it is issued.

### OAuth 2.1

The server publishes standard discovery documents, so a client that supports MCP authorisation needs only the endpoint URL:

* `/.well-known/oauth-protected-resource` names the authorisation server and the scopes.
* `/.well-known/oauth-authorization-server` names the authorise, token, and registration endpoints.
* `/oauth/register` accepts dynamic client registration (RFC 7591). No client secret is issued; clients are public.
* Authorisation code flow with PKCE (`S256`) is required. The token request must carry `resource=https://app.pioneeroptimisation.com/mcp` (RFC 8707); tokens are issued for that resource only.

On the consent page you pick the account the client may act in and confirm the scopes it asked for. Access tokens last one hour; the client renews them with a refresh token without asking you again.

### API token

In the dashboard open your account menu, then API tokens, then New token. Set a name, pick the account, tick the scopes the agent needs, and set an expiry date. The token is shown once, starts with `pio_`, and is stored as a hash: if you lose it, create another.

Treat the token as a secret. Keep it in an environment variable or the client's secret store, never in a repository.

## Connect a client

<Tabs>
  <Tab title="Claude Code">
    With OAuth, add the server and complete the login when prompted:

    ```bash theme={null}
    claude mcp add --transport http pio https://app.pioneeroptimisation.com/mcp
    ```

    With an API token, pass it as a header:

    ```bash theme={null}
    claude mcp add --transport http pio https://app.pioneeroptimisation.com/mcp \
      --header "Authorization: Bearer $PIO_MCP_TOKEN"
    ```
  </Tab>

  <Tab title="Cursor and JSON clients">
    Add the server to `mcp.json`. Omit `headers` to use OAuth. A literal token belongs only in the global file (`~/.cursor/mcp.json`): the project-level `.cursor/mcp.json` is committed with the repository, so never put a token there.

    ```json theme={null}
    {
      "mcpServers": {
        "pio": {
          "url": "https://app.pioneeroptimisation.com/mcp",
          "headers": {
            "Authorization": "Bearer pio_..."
          }
        }
      }
    }
    ```
  </Tab>

  <Tab title="Codex CLI">
    Add the server to `~/.codex/config.toml`, reading the token from an environment variable:

    ```toml theme={null}
    [mcp_servers.pio]
    url = "https://app.pioneeroptimisation.com/mcp"
    bearer_token_env_var = "PIO_MCP_TOKEN"
    ```
  </Tab>
</Tabs>

Confirm the connection by asking the agent to call `whoami`. The response names the account, the user, the scopes, the credential type, and the projects in the account.

## Tools

Tool arguments use numeric ids. `whoami` lists the project ids; `create_project` returns the id of the project it made.

| Tool | Scope | Arguments | Returns |
| - | - | - | - |
| `whoami` | `mcp:read` | none | Account, user, scopes, credential type, and the account's projects |
| `get_project_status` | `mcp:read` | `project_id` | The onboarding checklist: each step's state, completion time, and dashboard path, plus the next step |
| `check_integration` | `mcp:read` | `project_id` | Integration health: SDK liveness, event-type coverage, active users, the ladder of checks, and the next step |
| `get_model_lifecycle` | `mcp:read` | `project_id`, optional `model_id` | Readiness and lifecycle phase per model, with the project's latest evaluation summary |
| `get_experiment_evidence` | `mcp:read` | `project_id`, optional `metric_id` | The Experiments tab's results for the current measurement: the result for all models together, every model-vs-Baseline and model-vs-model comparison (verdict, range, evidence strength, whether it passes the multiplicity bar), the traffic-split check, and comparisons not yet available. See [Read results before moving traffic](#read-results-before-moving-traffic) |
| `get_guardrails` | `mcp:read` | `project_id` | The "Do no harm" checks: per check, the worst verdict, its margin, and each model's row, with what automatic rollback did about a harm (watching, would have switched off, switched off, stood down). Also whether automatic rollback is on, and `per_model_checks_dropped`: the models whose own checks didn't fit this update |
| `get_forecasts` | `mcp:read` | `project_id`, optional `model_id` | The Performance tab's forecast accuracy per model: per forecast the verdict (accurate, slipping, inaccurate, improving, uncertain, checking), users scored against users needed, and the recent check with the date it went off or came back |
| `create_project` | `mcp:write` | `name`, optional `store_platform`, `launch_stage`, `expected_mau_band` | The new project's id, pending a human attestation (see below) |
| `get_api_key` | `mcp:write` | `project_id` | The project's API key and account id for `PioSettings`. Account admins only; every reveal is audited |
| `create_action_definition` | `mcp:write` | `project_id`, `name`, `actions`, `default_action` | The new categorical definition. The name must match the key registered in `PioConfig.Actions` |
| `register_event_type` | `mcp:write` | `project_id`, `key`, optional `name` and `description` | The registered event type. Reserved platform keys are refused |
| `get_holdout_suggestion` | `mcp:read` | `project_id` | After a clear Benefit, the smallest share Baseline can shrink to at this traffic, with the values `shrink_baseline` needs; or why there is none |
| `shrink_baseline` | `mcp:traffic` | `project_id`, `expected_epoch_id`, `share` | Shrinks Baseline to the suggested share, the same action as the Experiments tab. Starts a fresh measurement. Refuses if the measurement or the suggestion changed; every change is audited |
| `get_winner_suggestion` | `mcp:read` | `project_id` | Applies when one model alone beats Baseline and passes the multiplicity bar, but the models together have not. Returns the shares now and after, any model still learning, and the values `shift_to_winner` needs; or why there is none |
| `shift_to_winner` | `mcp:traffic` | `project_id`, `expected_epoch_id`, `winner_model_id` | Moves the other models' new users to the winner, the same action as the Experiments tab. Baseline keeps its share; the other models stay live at 0%. Starts a fresh measurement. Refuses if the measurement or the winner changed; every change is audited |
| `go_live` | `mcp:traffic` | `project_id`, `model_id`, optional `share`, `rollback`, `rollback_was`, `confirm`, `expected_epoch_id` | A draft model's first live traffic, the same action as its go-live page. Without `confirm` it changes nothing and returns what the page shows. See below |
| `set_traffic_split` | `mcp:traffic` | `project_id`, `weights` (a list of `model_id` and `weight`), optional `confirm`, `expected_epoch_id` | The Models page's weights editor. Weights are relative; active models you leave out keep theirs, and a weight of 0 stops a model getting new users. See [Change models](#change-models) |
| `set_auto_rollback` | `mcp:traffic` | `project_id`, `enabled`, optional `enabled_was`, `confirm` | Turns automatic rollback on or off: when on, a model a safety check finds harming users is switched off without waiting for you |
| `promote_model` | `mcp:traffic` | `project_id`, `model_id`, optional `confirm`, `expected_epoch_id` | The challenger takes its champion's share, the champion is archived, and the challenger's action options replace the project's |
| `abandon_model` | `mcp:traffic` | `project_id`, `model_id`, optional `confirm`, `expected_epoch_id` | The challenger is archived at 0%; its share re-splits among the remaining models |
| `fork_model` | `mcp:write` | `project_id`, `model_id` | A challenger: a copy of an active model that gets no users until `go_live` |
| `archive_model` | `mcp:write` | `project_id`, `model_id` | Archives a model that gets no users. A model with users, Baseline, and a challenger under test are refused |
| `update_project` | `mcp:write` | `project_id`, optional `name`, `store_platform`, `app_store_identifier`, `play_store_identifier`, `launch_stage`, `expected_mau_band`, `test_device_identifiers` | The project's settings. Only the fields you pass change. The child-directed answer and deleting a project stay in the dashboard |
| `get_project_inputs` | `mcp:read` | `project_id` | The ids the authoring tools address: states, decisions with each option's stable id, rewards with the models using them, and event types |
| `create_state_definition` | `mcp:write` | `project_id`, `key`, `name`, `data_type`, optional `min`, `max` | A state the game reports. Catalogue keys (`std_*`) become standard states; any other key is custom |
| `update_state_definition` | `mcp:write` | `project_id`, `state_definition_id`, optional `key`, `name`, `data_type`, `min`, `max` | The changed state. Automatic states are read-only and standard states keep their key |
| `create_reward` | `mcp:write` | `project_id`, `name`, optional `discount_gamma`, `horizon_hours`, `rules` | A new reward. Omitted fields take the preset the dashboard starts from |
| `update_reward` | `mcp:write` | `project_id`, `reward_id`, optional `name`, `discount_gamma`, `horizon_hours`, `rules`, `confirm`, `expected_revision` | The changed reward. A change to what a live model optimises also needs `mcp:traffic` and a preview. See [Edit inputs a live model uses](#edit-inputs-a-live-model-uses) |
| `update_action_definition` | `mcp:write` | `project_id`, `action_definition_id`, `operation`, and that operation's fields, optional `confirm`, `expected_revision` | Renames a decision or an option, sets an option's traffic floor and cap, or sets when it is unavailable. See [Edit inputs a live model uses](#edit-inputs-a-live-model-uses) |
| `retire_choice` | `mcp:traffic` | `project_id`, `action_definition_id`, `choice_id`, optional `default_action`, `confirm`, `expected_revision` | Stops serving one option. The option is retired, not deleted: its id stays reserved and it can be reactivated on the decision's page |
| `list_model_templates` | `mcp:read` | `project_id` | The templates this project can start from, best fit first, with the signals each still needs |
| `create_model_from_template` | `mcp:write` | `project_id`, `template_key`, optional `version` | A draft model with the decision, states, goal event and reward it needs. Calling it again returns the same model |
| `reset_measurement` | `mcp:traffic` | `project_id`, optional `expected_epoch_id` | After the traffic-split check trips, starts a fresh measurement from tomorrow's new users. Refused while the check is not tripped |
| `list_notifications` | `mcp:read` | optional `unread_only`, `limit` | The credential's user's notifications in this account, newest first. Listing does not mark them seen |
| `mark_notification_read` | `mcp:write` | `notification_id`, or `all: true` | Marks one notification, or all of them, read |

Every tool response is a JSON document. When a tool cannot do what was asked it returns `error`, `message`, and `next_step`, so the agent can act on the failure rather than guess:

```json theme={null}
{
  "error": "project_not_found",
  "message": "No project 999999999 in this account.",
  "next_step": "Call whoami to list the projects this credential can see."
}
```

### check\_integration

This is the tool the agent runs after each change. The `rungs` array is the same ladder as the dashboard's integration-health view, and each rung carries its facts. An unsatisfied rung adds a `next_step` with a message, the dashboard path, and a link into these docs. The top-level `next_step` is the one thing to do now. `coverage` lists the event types the project expects, the ones seen, and any unregistered types the game is sending.

```json theme={null}
{
  "project": { "id": 5, "name": "MCP live check" },
  "status": "never",
  "streaming": false,
  "last_event_at": null,
  "sdk_version": null,
  "coverage": {
    "expected": ["decision", "reward"],
    "seen": [],
    "unseen": ["decision", "reward"],
    "unregistered": []
  },
  "users": { "active_24h": 0, "active_7d": 0 },
  "rungs": [
    {
      "code": "sdk",
      "title": "SDK connected",
      "state": "waiting",
      "next_step": {
        "code": "sdk_waiting",
        "message": "No events received yet. Initialise the SDK with this project's API key and run the game.",
        "path": "/projects/5/debug",
        "docs": "https://docs.pioneeroptimisation.com/getting-started/quickstart"
      }
    }
  ],
  "next_step": { "code": "sdk_waiting", "message": "..." }
}
```

`status` is `never` until the first event, `integrated` while events keep arriving, and `stale` once they stop; `streaming` is true while events are arriving now. See [Integration health](/dashboard/integration-health) for what each rung checks.

### go\_live

`go_live` is the go-live page for agents. It runs in two calls, so the agent acts on the same facts a person would see.

1. Call it with `project_id` and `model_id` only. Nothing changes. The response carries the suggested share and why, the split across live models, the timeline, the safety checks, the rollback setting, the per-option caps, and `expected_epoch_id`. If the model is not ready, `blocker` says why.
2. Call it again with `confirm: true`, the `share` you chose (5 to 50), and the `expected_epoch_id` from the first call. To set the project's automatic rollback, pass `rollback` with `rollback_was` set to the preview's `rollback.now`. A setting someone changed since the preview is kept. Omit both to keep the current setting.

```json theme={null}
{
  "preview": true,
  "ready": true,
  "share": { "suggested": 18, "chosen": 18, "floor": 5, "cap": 50, "state": "projected", "days_to_first_answer": 24 },
  "split": [
    { "model_id": 3, "name": "Control", "baseline": true, "new": false, "share_pct": 82.0 },
    { "model_id": 7, "name": "Tutorial Pacer", "baseline": false, "new": true, "share_pct": 18.0 }
  ],
  "guardrails": [{ "metric_id": "d7_retention", "label": "Still active after 7 days", "margin": "margin 3 pts", "status": "armed" }],
  "rollback": { "now": false, "after": false, "status": "Automatic rollback · off." },
  "expected_epoch_id": 412,
  "next_step": "To start, call go_live again with confirm: true, share: 18, rollback: false, rollback_was: false and expected_epoch_id: 412."
}
```

The confirmed call runs the same checks as the page, and refuses with one of these errors:

* `not_first`: the model has served users before. Change its share with `set_traffic_split`.
* `share`: the share is outside 5 to 50.
* `locked`: the model is larger than the free plan covers.
* `not_ready`: the model fails a readiness or fallback check; the message names it.
* `stale`: traffic changed since the preview. Preview again.
* `confirm_needs_preview`: `share` or `expected_epoch_id` is missing, or `rollback` came without `rollback_was`.

Every go-live is written to the audit log with the credential that made it. See [Going live](/dashboard/go-live) for how the share is chosen.

### Read results before moving traffic

`shrink_baseline`, `shift_to_winner` and `set_traffic_split` act on results. Read them first. `get_experiment_evidence` and `get_guardrails` return the same results the Experiments tab shows, and `get_forecasts` returns the Performance tab's forecast accuracy panel.

* `measurement.superseded` is `true` when the traffic changed and a fresh measurement has not reported yet. Those rows are the last results from the previous measurement.
* Every model in these results, and in `get_model_lifecycle` and `set_traffic_split`, carries `traffic_weight` and `serving_class`. Nothing is left out for being at 0%:
  * `serving`: the model gets traffic now.
  * `zero_with_evidence`: it is at 0% now, but the results shown still include it. The pages show it muted with a "0% now" tag.
  * `not_receiving`: it is at 0% with nothing in these results. It may be a challenger waiting for `go_live`, a retired model, or one that never served. The pages leave it out of filters and lists of results.
* `srm.tripped` means the traffic split did not match the configured weights, so no verdict can be trusted. `reset_measurement` starts a fresh one.
* `ebh` on a comparison is `passes` or `does_not_pass` against the multiplicity bar that protects against a lucky pair among many. It is `null` when there are too few comparisons for the bar to apply, the same as the tab.
* A guardrail row with `decision: "harm"` is a model making things worse on that check. Don't move traffic toward it.
* `per_model_checks_dropped` in `get_guardrails` lists the models that have no checks of their own. With many models live, only the checks across all models combined fit each update. When it isn't empty, a pooled "all clear" says nothing about any one of those models, and automatic rollback can't switch a single model off. `shift_to_winner` refuses those models. Archive models you've finished with to turn per-model checks back on.

## Change models

`set_traffic_split`, `set_auto_rollback`, `promote_model`, and `abandon_model` run the Models page's own actions with its checks, in two calls like `go_live`:

1. Call without `confirm`. Nothing changes. The response shows the state now and after, and the values the second call needs: `expected_epoch_id`, or for `set_auto_rollback`, `enabled_now`.
2. Call again with `confirm: true` and those values: `expected_epoch_id`, or `enabled_was` set to the preview's `enabled_now`. If traffic or the setting changed since the preview, the call refuses with `stale` and changes nothing.

A model's first users always come through `go_live`: `set_traffic_split` refuses with `first_traffic` for a model that has never served, and its rows mark such a model `requires_go_live: true`. `get_model_lifecycle` lists each model's `traffic_weight`, `forked_from_id`, and `challenger_state`, which is what `promote_model` and `abandon_model` act on.

`fork_model` and `archive_model` never move traffic, so they need only `mcp:write` and run in one call. To archive a model that still gets users, set its weight to 0 with `set_traffic_split` first.

Every change, from an agent or from the Models page, is written to the audit log in the same transaction as the change.

## Edit inputs a live model uses

Most authoring applies in one call. Three edits change what live players get, so they follow the same two calls as `go_live`:

* `update_reward`, when an active model trains on the reward and the edit changes its rules, horizon or discount.
* `update_action_definition`, for any operation except `rename_choice`, when an active model uses the decision.
* `retire_choice`, always.

1. Call without `confirm`. Nothing changes. The response shows the current values, the proposed change, the live models it reaches, and `expected_revision`.
2. Call again with the same arguments, `confirm: true`, and that `expected_revision`. If the input, the requested change, or the set of live models differs from the preview, the call refuses with `stale` and changes nothing.

Without `mcp:traffic`, these edits refuse with `traffic_scope_required`. A rename that only changes a reward's name, or an option's label, applies at once: options keep their stable ids, so the live model serves the same arms.

A confirmed change to a live champion's reward does not rewrite what it serves. It creates a challenger at 0% that trains on the edited reward, for you to compare and promote. A reward used only by Baseline or a promoted challenger changes in place.

`update_action_definition` takes one `operation` per call:

| `operation` | Fields | Effect |
| - | - | - |
| `rename` | `name` | Renames the decision. The name must match the key the game registers |
| `rename_choice` | `choice_id`, `label` | Renames an option. Its id never changes and old labels stay reserved |
| `update_steering` | `choice_id`, optional `min_serve_prob`, `max_serve_prob`, `logit_prior` | Sets an option's traffic floor and cap (fractions from 0 to 1) and its prior (from -5 to 5). Values you omit keep their current setting |
| `update_mask_rule` | `choice_id`, `mask_rule` | Sets when an option is unavailable, for example `{"op": "lt", "input": "std_level_index", "value": 3}`. `null` clears it |

`reset_measurement` follows the same pattern: the first call reports the tripped measurement's `epoch_id`, and the second call passes it as `expected_epoch_id`.

## The integrate-and-verify loop

1. `whoami` to confirm the account and find or choose a project. With `mcp:write`, `create_project` makes a new one.
2. `get_api_key` for the `ApiKey`, `AccountId`, and `ProjectId` that go into `PioSettings`. Keep the key out of source control.
3. `create_action_definition` for each key the game registers, `create_state_definition` for each state it reports, and `register_event_type` for each event type it tracks. `get_project_inputs` lists what exists.
4. Integrate the SDK following the [quickstart](/getting-started/quickstart) and run the game.
5. `check_integration`. If `status` is still `never`, the `next_step` says why. Fix, run, repeat.
6. `get_project_status` to see which onboarding steps remain. `list_model_templates` and `create_model_from_template` make a draft model; `get_model_lifecycle` shows what it still needs before `go_live`.

## Projects created by an agent

A project made through `create_project` starts under a not-child-directed default: its API key is published at once and events are accepted. A member of your studio must still confirm the child-directed answer in the project's settings within 14 days; agents never answer it. While the confirmation is outstanding:

* `create_project` and `get_api_key` return `attestation_pending: true` with a `confirm_by` date; once a studio member answers, the field reads `null`.
* `check_integration` and `get_project_status` keep the confirmation as the next step, with the settings path.
* Past `confirm_by` the key is unpublished and events stop until someone answers. The dashboard shows the same warning on every page of the project.

The person who confirms is recorded with the attestation, exactly as for a project created in the dashboard. See [Consent and privacy](/integration/consent-and-privacy) for what the answer changes.

## Limits and audit

* `get_api_key` is limited to account admins. A non-admin credential gets a `forbidden` error naming who can help.
* Each key reveal, project or input change, go-live, model change, and traffic change is written to the account's audit log with the credential, the user, and the request's IP address.
* Unauthenticated requests to `/mcp` are limited to 20 per minute per IP; client registration to 5 per minute; token requests to 30 per minute.
* Tool calls are recorded (tool name, duration, outcome, never arguments) so the tool set can improve with use.
* Nothing in this tool set deletes or rotates anything. `archive_model` archives and `retire_choice` retires; both can be undone from the dashboard. Deleting a project stays in the dashboard. Rotating an API key, when it ships, will be a separate, explicit tool.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.