# Arena MCP: connect an AI agent

Every hosted bot belongs to a user account. MCP is one of the complete
[onboarding paths](/docs/api#account-onboarding); its browser flow creates the user and
chooses a handle before consenting to bot and event access. Existing bots can be claimed
on the account page with their token; MCP registration does not claim or transfer them.

When `GET https://arena.victris.ai/api` reports a non-null `mcp`, add that URL to your
AI client's remote MCP settings. The transport is Streamable HTTP. It opens a browser
to sign in with Google, GitHub or email, choose a public handle, and approve the client.
Email signup still requires confirmation in the browser that requested it. After signup,
the pending connection is restored; account passwords are never sent to an MCP tool.

The client can read your handle and bot list, register new bots within your account's
quota, delete a bot that has never played, retire an owned bot while preserving its history,
and inspect or join event ladders under the same admission rules as the browser. The agent
must ask for your explicit approval before retiring a specific bot or joining an event.
My Bots (`/account`) lists connected clients and lets you disconnect them. Arena
rejects their existing OAuth tokens immediately after disconnection. Bot tokens they
already received remain valid: rotate those separately if needed.

An MCP server does not run code on your computer. Use a local coding agent or the CLI to
download a starter, write and test your bot, and run it with its own WebSocket token.
Clients without remote MCP/OAuth support can keep using an agent key and the HTTP API.

## Without a browser: an agent key

A client that can't open a browser for OAuth (over SSH, in a container) can send one of
your agent keys from My Bots instead, as `Authorization: Bearer <key>`. Making the key is
the approval: new keys have the same tools and quota, and work until you revoke them on My Bots.
Keys created before account actions were introduced retain registration access; create a
new key to authorize MCP retirement and enrollment.
For Claude Code:

```bash
claude mcp add --transport http arena https://arena.victris.ai/mcp --header "Authorization: Bearer <agent key>"
```

Keep the key out of shared config and source control, as you would a bot token. An
admin's key is also an admin credential for the HTTP API: keep it off machines you don't trust.
An authorized operator agent can read, preview and apply live limits with the HTTP settings
endpoints or `arena admin settings`, using that admin key without a browser. See
[agent settings administration](/docs/api#agents-changing-operational-settings). These are
HTTP/CLI operations; the MCP tools below retain their documented player scope.

## Tools

| tool | input | result |
| --- | --- | --- |
| `whoami` | none | handle, bot limit, number of bots counting against it (all but retired ones), account page URL and `account_actions` (whether retirement/enrollment consent is present) |
| `list_my_bots` | none | this account's public bot records with `status` and `played`; no tokens |
| `register_bot` | `{"name":"my-bot"}` | name, secret token, bot WebSocket endpoint, storage note |
| `delete_bot` | `{"name":"my-bot"}` | `{name, outcome: "deleted"}` for a bot with no match on file; `bot_has_played` otherwise (use `retire_bot` with approval). Marked destructive, so clients ask first |
| `retire_bot` | `{"name":"my-bot","confirmed":true}` | `{name, outcome: "retired"}`; frees quota, stops play and keeps name, versions and history, including for unplayed bots. Destructive and idempotent; ask the owner first |
| `list_my_ladders` | `{"ladder":"optional-id"}` or none | `{ladders}` with definitions, your `membership` (null, pending, active or revoked), `enrollment_active` and exact `enrollment_url`; a supplied ID includes an unlisted ladder. Unknown IDs return `unknown_ladder` |
| `enroll_ladder` | `{"ladder":"event-id","confirmed":true,"invitation":"optional code"}` | `{ladders}` with current memberships. Open becomes active, approval stays pending, invite requires a valid code. Ask the owner first; repeated enrollment is safe |
| `get_agent_guide` | none | authoring guide, starter download index and simulator links |

Save a new bot's token in a private environment file; never commit it or print it in
shared logs. Tokens are shown at creation only and stored hashed. Registration is not
idempotent: if a response is lost, inspect My Bots instead of creating another identity
or automatically rotating a token. A duplicate name fails without changing the bot.
For a chat-only client, copy the new token to the machine where you will run the bot.

`retire_bot` and `enroll_ladder` require both account-actions consent and `confirmed: true`.
The flag records the client's assertion of the owner's approval; it is not a separate human
approval channel. Clients must obtain explicit approval for the named bot/event before calling.
Existing OAuth grants and agent keys keep their previous write scope. If `whoami` reports
`account_actions: false`, these tools return `mcp_account_actions_required`: disconnect and
reconnect the OAuth client to review the updated browser consent, or create a new agent key.
Read-only membership discovery remains available, including a browser enrollment link.

Retirement never deletes played history or releases the name, and repeating it does not
add another retirement audit entry. Suspended/banned bots return `bot_moderated`; another
account's bot or a human seat returns `no_such_bot`. `bot_limit` points to retirement as
a way to release quota. Claiming bots and token/version management remain available on
My Bots or their documented HTTP routes.

Before starting an event bot, call `list_my_ladders` with the intended ladder ID. Public
needs no membership. For an event, `enrollment_active: true` means admission is active;
the schedule and enabled state still govern queueing. Pending admission needs staff
approval. Revoked membership includes the staff reason and cannot be self-restored.
Enrollment preserves `invitation_required`, `invalid_invitation`, `membership_revoked`
and `ladder_closed` errors, and permits preregistration before opening. Browser account
routes still reject MCP credentials; enrollment runs directly through the shared policy.

## Operator setup

MCP is off by default. Implementing the endpoint does not change your Supabase project's
settings. Use a staging project first; Supabase's OAuth server is currently beta.

1. Configure the existing Supabase sign-in methods and asymmetric JWT signing keys
   (ES256 or RS256), as for ordinary Arena accounts.
2. Enable Authentication → OAuth Server. Allow dynamic client registration for clients
   that need it, or pre-register the clients you support. Registering an OAuth client
   registers the AI application, not a player account.
3. Set the Auth Site URL to the ladder's public origin and the authorization/consent
   path to `/oauth/consent`. Keep `/account` in the allowed sign-in redirect URLs;
   redirects also carry `next` to preserve pending consent. Production uses HTTPS.
4. Install [the audience hook](https://github.com/Victris-AI/saintcon-arena-fighter-game/blob/master/supabase/arena-mcp-audience.sql), replacing its example
   audience with the canonical public MCP URL (the ladder's `--public-url` plus `/mcp`).
   Select `public.arena_mcp_access_token` under Authentication → Hooks → Custom Access
   Token. If a hook already exists, merge the audience logic into it. The sample binds
   every OAuth-server client's access tokens to this Arena MCP resource, so use a
   dedicated project or adapt it if other applications use this OAuth server.
   Normal Google/GitHub/email browser sessions retain the `authenticated` audience.
5. Set `ARENA_MCP_ENABLED=1` on the ladder, or use `arena serve --mcp`. Set its canonical
   `--public-url`; discovery and JWT validation use it, never request Host headers.
   The production Docker Compose file forwards this runtime variable. In Coolify,
   set it on the production application and deploy the release containing MCP;
   changing the environment alone does not update the running container.
6. Test discovery, browser consent, registration and revocation with the MCP Inspector
   and the actual AI clients you intend to support before enabling it for players.

For local development, the hook's example audience is `https://arena.victris.ai/mcp`.
With Supabase Auth configured separately, start `npm run arena -- serve --mcp`.
The account and consent pages are built by Vite and served by the ladder, as all other
Arena pages are. `npm run dev:web` proxies MCP and discovery to the local ladder.
Its frontend origin must match the configured Auth Site URL and the ladder public URL
for browser consent requests; serving the built pages is simpler for OAuth testing.

```bash
npx @modelcontextprotocol/inspector
```

Choose Streamable HTTP and connect to `https://arena.victris.ai/mcp`. Verify a fresh signup,
an existing account, cancellation, handle conflicts, duplicate bot names, the bot limit,
and disconnection. Repository tests use the official MCP SDK, signed JWT fixtures and
a fake Supabase OAuth API; they do not verify a live project's settings or every client.

## Authentication and permissions

The MCP endpoint publishes `/.well-known/oauth-protected-resource/mcp` (also available
at `/.well-known/oauth-protected-resource`). A missing/invalid token gets a `401` and a
`WWW-Authenticate` challenge pointing there. Metadata names the Supabase Auth issuer.

MCP requires either one of the account's agent keys, or a signed Supabase access token
whose audience includes the canonical MCP URL and whose signed `client_id` identifies a
locally approved client for that user.
Browser sessions cannot call MCP; OAuth-client tokens cannot call any `/api/account`
route, even if they also carry the ordinary `authenticated` audience. Tool calls invoke
the same registration logic as browser and agent-key requests, with no service-role key
or token forwarding to another API.

Supabase currently supports identity scopes (`openid`, `email`, `profile`, `phone`),
not custom scopes such as `bots:create`. Arena stores its bot/event permission
in SQLite, separately for each account/client pair, and checks it on every MCP request
and tool call. The consent page verifies client identity against Supabase before saving
permission. Its routes accept browser JWTs, not cookies or user-supplied client IDs.

Revocation first disables the local grant, then asks Supabase to revoke OAuth consent.
If Supabase is unavailable, Arena access is still disabled; the page reports that OAuth
cleanup failed. Clear that client's OAuth grant in Supabase before reconnecting if it
auto-approves without displaying consent. Reapproval never revives tokens issued before
revocation. Because JWT timestamps use seconds, a new token must be issued after the
revocation second; refresh/reconnect if an immediate retry is refused.

The transport is stateless and POST-only: no persistent MCP session or background SSE
channel. It returns JSON responses, rejects foreign browser origins, bounds input bodies
at 16 KiB, and marks secret-bearing responses `no-store`. Audit entries identify MCP
client IDs (`agent_key:<id>` for a key) without recording credentials or invitation codes.
SQLite schema 18 adds account-actions consent to grants and keys, defaulting existing
credentials to registration-only. Older builds reject it: back up before upgrading,
and restore a compatible database backup to roll back.

Sources: [Supabase MCP deployment](https://supabase.com/docs/guides/ai-tools/byo-mcp),
[OAuth setup](https://supabase.com/docs/guides/auth/oauth-server/getting-started),
[OAuth scopes](https://supabase.com/docs/guides/auth/oauth-server/oauth-flows),
[access token hooks](https://supabase.com/docs/guides/auth/auth-hooks/custom-access-token-hook),
[MCP authorization](https://modelcontextprotocol.io/specification/latest/basic/authorization).
