# OAuth 2.1 — connecting a third-party MCP client

For a client that has **never heard of us**: claude.ai, ChatGPT, Cursor, the MCP
Inspector, or one you wrote this morning. No prior agreement, no email, no
account.

**The wallet is still the identity.** Sign-in is Sign-In with Ethereum
(EIP-4361) and the `sub` of every token is a CAIP-10 account
(`eip155:8453:0x…`). What OAuth adds is a way for an *application* to act as
that wallet, with scopes you chose and an expiry you can wait out.

> **If your client can sign requests, use ERC-8128 instead** — see
> [`signing.md`](signing.md). It authenticates the REQUEST rather than the
> caller, needs no token, and a captured header cannot be replayed against
> another route, host or body. OAuth exists for clients that cannot do that.

## Is it on?

Everything here answers **404** unless the deployment has the rail enabled.
Probe, do not assume:

```bash
curl -s https://mcp.execution.market/.well-known/oauth-protected-resource/mcp
```

`GET /api/v1/auth/info` reports every mode and its real state.

## Connecting from claude.ai, click by click

The six steps, in the order the interface presents them:

1. **Settings → Connectors → Add custom connector.**
2. **URL:** `https://mcp.execution.market/mcp/` — **with the trailing slash**.
   Without it the transport is not there, and the connector fails before any of
   this starts.
3. Claude fetches the endpoint, gets the 401, follows `resource_metadata` to the
   RFC 9728 document and that to the authorization server. A **Sign in now**
   button appears.
4. **Sign in now → Use Claude's published identity.** That is CIMD: Claude's
   `client_id` *is* the URL of its metadata document, which we fetch and
   validate. Nothing is registered with us beforehand. The alternative,
   *Register a new client*, is Dynamic Client Registration and works too.
5. **The consent screen** opens at `auth.execution.market`. It asks, in this
   order:
   * which scopes to grant — the five basic ones (`task:read`, `task:write`,
     `task:cancel`, `worker:apply`, `agent:publish`) are **always offered,
     pre-ticked**, whatever the client asked for; untick any you do not want.
     The token carries what stays ticked, which can be more than was asked
     (RFC 6749 §3.3) — the token response's `scope` says exactly what. So one
     sign-in covers the session instead of one step-up per tool.
     `agent:approve` arrives **un-ticked**, in its own box, only when the client
     asks for it, with the two limits to type if you do tick it;
   * which wallet — first **Continue with PayBox** when the deployment has it
     on (see [Continue with PayBox](#continue-with-paybox--a-linked-account)),
     then three options at the same level: **PayBox** (paste the EVM address
     `list_credentials` reports; no browser wallet needed), a wallet in this
     browser (MetaMask and anything else speaking EIP-1193), or another wallet
     by address;
   * then it shows the **exact EIP-4361 text** for that wallet and those
     scopes, and how to sign it. Sign it verbatim: one character changes the
     recovered address.
6. **With PayBox there is nothing to paste back.** The screen hands you one JSON
   block; paste it into the claude.ai chat where PayBox is connected and stop
   there. The block tells the agent to sign the message with
   `request_wallet_sign`, poll `get_request`, and **deliver** the signature to
   `POST /oauth/consent/{rid}/signature`. The screen is polling, so it completes
   the redirect by itself. See
   [Signing from a wallet that is not in a browser](#signing-from-a-wallet-that-is-not-in-a-browser).
7. Claude is redirected back with a code, exchanges it, and the **tools appear**
   in the connector. `em_get_tasks` is the cheapest thing to try first.

To disconnect: remove the connector in claude.ai, and `POST /oauth/revoke` with
the refresh token to kill the family server-side.

### The same thing from any other client

The MCP Inspector is the shortest generic path, and it uses **DCR** rather than
CIMD:

```bash
npx @modelcontextprotocol/inspector
# Transport: Streamable HTTP
# URL:       https://mcp.execution.market/mcp/
# then: Connect → it registers itself at /oauth/register, opens the same
#       consent screen, and completes the code exchange for you.
```

A loopback `redirect_uri` (`http://127.0.0.1:…`) is accepted for exactly this
case, and the consent screen says so out loud when it sees one. Any SDK that
implements the MCP authorization spec — the TypeScript and Python ones do —
needs nothing from us but the URL.

## The flow

### 1. Get the 401

```http
POST /mcp/ HTTP/1.1
Host: mcp.execution.market
Content-Type: application/json

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
```

```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.execution.market/.well-known/oauth-protected-resource/mcp", scope="task:read"
X-EM-Auth-Schemes: ERC8128 realm="execution-market"
```

The challenge asks for **`task:read` and nothing else** — the minimum
`initialize` and `tools/list` need. Ask for more only when an operation tells
you to (see *Step-up*, below).

### 2. Read the two documents

```
GET https://mcp.execution.market/.well-known/oauth-protected-resource/mcp
GET https://auth.execution.market/.well-known/oauth-authorization-server
```

The first names the authorization server; the second describes it. **The
`issuer` in that document must equal the host you fetched it from** — if it does
not, discard it (RFC 8414 §3.3).

Two forms of the resource document exist, and they name **different**
identifiers on purpose:

| URL | `resource` |
|---|---|
| `/.well-known/oauth-protected-resource/mcp` | `https://mcp.execution.market/mcp` |
| `/.well-known/oauth-protected-resource` | `https://mcp.execution.market` |

### 3. Get a client_id

**Client ID Metadata Document (preferred).** Your `client_id` IS an `https` URL
with a path, pointing at your own metadata. Nothing to register:

```json
{
  "client_id": "https://yourapp.example/oauth/client.json",
  "client_name": "Your App",
  "redirect_uris": ["https://yourapp.example/callback"],
  "token_endpoint_auth_method": "none"
}
```

The `client_id` inside must match the URL **exactly**. This is what claude.ai's
*"Use Claude's published identity"* uses.

**Dynamic registration (fallback, deprecated by the MCP spec).**

```http
POST https://auth.execution.market/oauth/register
Content-Type: application/json

{"client_name": "Your App", "redirect_uris": ["https://yourapp.example/callback"]}
```

Public clients only. **No secret is ever issued**, which is why PKCE is not
optional.

### 4. Authorize

```
GET https://auth.execution.market/oauth/authorize
  ?response_type=code
  &client_id=https://yourapp.example/oauth/client.json
  &redirect_uri=https://yourapp.example/callback
  &code_challenge=<base64url(sha256(verifier))>
  &code_challenge_method=S256
  &scope=task:read
  &resource=https://mcp.execution.market/mcp
  &state=<random>
```

* **PKCE with `S256` is mandatory.** No `code_challenge`, or `plain`, is refused.
* `redirect_uri` is matched **exactly** against your registration — character
  for character, no normalisation.
* `resource` (RFC 8707) becomes the token's `aud`.

The user signs in with their wallet and picks scopes. You get back
`?code=…&state=…&iss=…`. **Check `iss`** (RFC 9207) before sending the code
anywhere.

### 5. Exchange

```http
POST https://auth.execution.market/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=<code>&code_verifier=<verifier>
&client_id=<client_id>&redirect_uri=<redirect_uri>
```

```json
{"access_token": "eyJ…", "token_type": "Bearer", "expires_in": 3600,
 "refresh_token": "…", "scope": "task:read"}
```

The code is **single-use and lives 60 seconds**. Redeeming it twice revokes
everything it issued.

### 6. Call the MCP endpoint

```
Authorization: Bearer eyJ…
```

On every request, including within one session.

## Refresh

Refresh tokens **rotate on every use**, and presenting one that was already
rotated **revokes the whole family**. That is not a punishment: it means
somebody holds a copy, and there is no way to tell whether it is you or them.
Store exactly one, replace it on every refresh, and never retry a refresh with
the old value.

## The nine scopes

| Scope | What it allows | Bearer? |
|---|---|---|
| `task:read` | Read tasks, applications, submissions | yes |
| `task:write` | Edit a task you published; assign a worker | yes |
| `task:cancel` | Cancel a task you published | yes |
| `worker:apply` | Apply to tasks | yes |
| `agent:publish` | Publish tasks and service listings | yes |
| `agent:approve` | **Release escrow** on approval | yes, see below |
| `worker:submit` | Submit work | **no** in v1 |
| `worker:withdraw` | Withdraw earnings | **no** |
| `reputation:rate` | Rate a counterparty | **no** |

The last three answer **403** for a bearer whatever scopes it holds, and so do
`/escrow`, `/account`, `/disputes`, `/evidence`, `/reputation`, `/admin` and
`/h2a`. A rating is an act of its author; withdrawing sends money to an address.
Use ERC-8128 for those.

### Step-up

An operation you lack the scope for answers **403**, not 401:

```http
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", error_description="the token does not carry the scope this operation needs", resource_metadata="https://mcp.execution.market/.well-known/oauth-protected-resource/mcp", scope="agent:approve"
```

Re-authorize asking for that scope **plus the ones you already hold**, or the
user loses the rest.

**A step-up is one click for the user.** Once a wallet has been proven on the
consent screen (a signature, from a browser wallet or PayBox), that browser
remembers it **for that client** for **seven days**: the next authorization
from the same client shows *"Allow `<scope>`?"* and one button — no PayBox, no
signature. The memory is an `HttpOnly`, `Secure`, `SameSite=Lax` cookie on
`/oauth`, bound to the wallet and the `client_id` and signed by the
authorization server; using it never extends it. Two exceptions:

* **`agent:approve` always proves the wallet again**, remembered or not.
* **"Use a different wallet"** on that screen forgets it (so does clearing the
  site's data); the next sign-in asks for a signature.

It changes nothing for ERC-8128 clients, and nothing about tokens already
issued.

## Money: two signatures a token cannot replace

**Assigning a task** always needs a per-operation EIP-3009 authorization. Not our
policy — the nonce is `AuthCaptureEscrow.getHash(paymentInfo)`, which includes
the receiver, so the authorization cannot exist before the worker is chosen.

Call `em_assign_task` without it and you get the complete challenge back, with
the nonce, expiries and salt already computed, plus the literal PayBox and OWS
calls that sign it. Call it again with the result in `payment_auth`.

```json
{
  "error": "escrow_signature_required",
  "challenge": { "accepts": [ { "extra": { "typed_data": { … } } } ] },
  "wallet_action": {
    "kind": "eip712",
    "typed_data": "$.challenge.accepts[0].extra.typed_data",
    "paybox": { "tool": "request_wallet_sign", "intent": { "op": "typedData", … } },
    "ows": { "tool": "ows_sign_eip3009", … },
    "how_to_return": { "tool": "em_assign_task", "parameter": "payment_auth", … }
  }
}
```

### `wallet_action` — one shape, everywhere

Every tool that needs a key we do not hold answers with a `wallet_action` block
instead of acting, and **nothing is changed by the call that returns one**. Read
four fields and you can follow any of them:

| Field | What to do with it |
|---|---|
| `kind` | `eip712` — sign `typed_data`. `eip712_incomplete` — it cannot be signed from here; read `instead`. `paybox_signed` — the wallet's linked PayBox already signed: call `how_to_return.tool` with `how_to_return.arguments`. `paybox_pending` — PayBox is waiting for the owner's passkey: call the same tool again with the same arguments once they approve. |
| `paybox` | The literal `request_wallet_sign` call, arguments filled in. Copy it; do not compose it. The one exception is `em_assign_task`, whose `intent.typedData` is a JSON path into the same response (`$.challenge…`) because the struct is large: substitute the object it points at, unchanged. |
| `ows` | The equivalent OWS tool. |
| `how_to_return` | The **tool** and the **parameter** that take the signature. Never a header: an MCP client cannot set one. |

The blocks in this version:

| Tool | `kind` | Returns through |
|---|---|---|
| `em_assign_task` | `eip712` (escrow authorization) | `payment_auth` |
| `em_approve_submission` | `eip712` (`ReleaseApproval`), only when the token is past its consented limit | `approval` |
| `em_order_service` | `eip712_incomplete` | — see below |

`em_order_service` is the one that cannot be completed in a single step by a
wallet that only signs what it is handed, and it says so rather than guessing:
ordering **creates** the task, and the EIP-3009 nonce covers a salt derived from
the task id. Its `instead` names the route that does work — `em_publish_task`,
then `em_assign_task`, whose challenge is complete.

**Approving a submission** releases escrow. Either sign an `X-EM-Approval` per
operation, or hold the `agent:approve` scope — consented on its own un-ticked
box, with two numbers the user types on the consent screen and signs inside the
EIP-4361 message: the most **one** approval may release, and **how many**
approvals the token gets in total. The screen will not accept more than
$100.00 each or 50 of them. That token lives **15 minutes** and a refresh does **not**
renew the scope.

Both numbers ride as signed claims (`approve_max_usd`, `approve_max_count`), so
they cannot be raised after the fact — editing a row somewhere would otherwise
change retroactively what an already-issued token may do. The amount checked is
what the release would actually move: the larger of the task's bounty and the
escrowed amount.

Over the amount, or out of approvals, and the approval answers 403 naming which
limit you hit. A per-operation `ReleaseApproval` is the way through either, and
it spends none of the count: it is a fresh signature naming that one submission,
which is strictly more than the scope it stands in for. On REST it is the
`X-EM-Approval` header; **over MCP it is the `approval` argument of
`em_approve_submission`**, and the refusal returns the exact typed data to sign
in its `wallet_action` block.

## Continue with PayBox — a linked account

When `GET /api/v1/auth/info` reports `paybox_connect.enabled: true`, the consent
screen offers **Continue with PayBox** above the other ways. Execution Market is
then a client of PayBox's own OAuth server, and the order is fixed by PayBox:

1. **Continue with PayBox** → PayBox's consent screen, where you approve
   Execution Market and choose the wallets it may use.
2. Back on the consent screen with the wallet already chosen — read from PayBox,
   never typed.
3. **The first time only:** open *Generate Signing Key* in PayBox and paste the
   `pbxk1.` key into the field. PayBox recognises the Execution Market agent
   only after step 1, which is why this comes second. The key is stored
   encrypted, never shown again, and lets Execution Market *ask* PayBox for
   signatures — it is not your wallet's key.
4. **Sign with PayBox.** Execution Market asks PayBox to sign the exact EIP-4361
   text on screen. With a passkey-protected wallet PayBox asks you first and the
   screen waits; then the sign-in completes like any other.

What bounds it:

* **A link is not a login.** Signing a sign-in through PayBox needs step 1 in
  THAT browser, for THAT sign-in. A linked address typed into another sign-in
  gets nothing signed.
* **Per-operation signatures for tools** (`em_assign_task`'s escrow
  authorization, `em_approve_submission`'s `ReleaseApproval`) are fetched from
  PayBox only for the wallet the MCP transport verified, and only when that
  wallet asks its owner — an `autonomous` PayBox wallet, or any request PayBox
  hands back without an approval step, keeps the recipe
  (`wallet_action.paybox_link.reason`: `autonomous_wallet` /
  `approval_not_required`), because the consent
  message says assigning and releasing each need a separate signature from you.
  What comes back is filled into `payment_auth` / `approval` and verified on the
  next call exactly like a signature you pasted.
* **Unlinking.** From the consent screen, or `DELETE /api/v1/account/paybox-link`
  (ERC-8128): every stored PayBox secret is deleted. A revocation made in
  PayBox shows up as `status: unlinked_by_paybox` on
  `GET /api/v1/account/paybox-link`, and nothing can be signed from then on.
  PayBox publishes no revocation endpoint, so removing the agent from your
  vault is done in PayBox.

## Signing from a wallet that is not in a browser

The consent screen shows the exact EIP-4361 text and the exact call to sign it:

```json
{"tool": "request_wallet_sign",
 "credential_id": "<from list_credentials>",
 "intent": {"op": "message", "message": "<the text, verbatim>"}}
```

Sign the text **exactly** as shown — one character changes the recovered address.

### Delivering the signature instead of pasting it

Choose **PayBox** and the screen hands you one JSON document with three things in
it: what this is, the `request_wallet_sign` call above with the message already
filled in, and where to deliver the answer. Paste it into the chat and stop.

```http
POST /oauth/consent/{rid}/signature
Content-Type: application/json

{"delivery_token": "<from the block>", "signature": "0x…"}
```

`GET` on the same path with the two values as query parameters works too, for an
agent that cannot POST.

* **The `delivery_token` is the authorisation**, and it is a MAC over the wallet,
  the scopes and the approval caps — so none of those can be changed by whoever
  posts. It expires with the sign-in (ten minutes).
* **One delivery.** A second answers `409 delivery_already_used`.
* **A signature that does not verify** answers `400 signature_mismatch` and
  leaves the sign-in open, so signing again is safe. The screen shows the reason.
* **`200` means stop.** The browser tab finishes the redirect on its own; do not
  report the signature back to the user.

The screen polls `GET /oauth/consent/{rid}/status`, which needs the `HttpOnly`
CSRF cookie of that sign-in — so only the browser that started the flow can read
a delivered signature back.

OWS: `ows_sign_eip191`, then paste the `0x…` into the form field, which is still
there for any wallet that cannot make an HTTP request.

## Errors

| Code | Meaning | Do |
|---|---|---|
| `401` + `Bearer …` | no credential, or an invalid/expired token | run the flow; if you had a token, refresh |
| `403 insufficient_scope` | valid token, missing scope | step up, keeping existing scopes |
| `403 bearer_not_allowed` | the door refuses delegated tokens entirely | use ERC-8128 |
| `403 approval_amount_over_limit` | release exceeds the consented cap | sign the `wallet_action`'s `ReleaseApproval` and pass it as `approval`, or re-authorize |
| `403 approval_count_exhausted` | the token's approvals are used up | sign a `ReleaseApproval`, or re-authorize |
| `403 approval_budget_missing` | the token carries no limit, so none can be checked | re-authorize and choose one, or sign a `ReleaseApproval` |
| `403 approval_amount_unknown` | neither the bounty nor the escrow names an amount | sign a `ReleaseApproval` |
| `403 approval_budget_unavailable` | the approval counter is unreachable right now | retry, or sign a `ReleaseApproval` |
| `404` on a delivery | forged/expired token, wrong rid, or no such sign-in | one answer for four causes, on purpose; restart the sign-in |
| `409 delivery_already_used` | a signature was already delivered for that sign-in | nothing — the tab has it |
| `400 invalid_grant` on refresh | rotated or reused | **stop**; the family is revoked; sign in again |
| `429` | rate limited | honour `Retry-After` |

## What a token can never do

Reach `/escrow`, `/account`, `/disputes`, `/evidence`, `/reputation`, `/admin`
or `/h2a`. Move funds without the signature the protocol demands. Outlive its
`exp` (one hour, fifteen minutes with `agent:approve`). Survive a revocation:
`POST /oauth/revoke` kills a refresh token and its whole family.
