Skip to main content
Glama
opcode-fi

mcp-opcode

Official
by opcode-fi
README.md
# mcp-opcode

Python SDK for the [opcode](https://opcode.fi) gasless trading API, and the MCP server built on
top of it. It lets an agent, bot or script do what a user does in the opcode app — over plain
HTTP, with its own wallet.

This repo holds two things: `opcode_sdk`, the client library, and `opcode_mcp`, an MCP server
built on it that lets an AI agent trade with its own wallet under human approval.

## Design

- **Sign-then-submit. The client never holds a private key.** Every signed action is two calls:
  `prepare_X(...)` returns the exact EIP-712 / EIP-7702 payload(s) to sign, the caller signs them
  with whatever custody they have, and `submit_X(prepared, sig)` POSTs the result. `OpcodeClient`
  takes no signer.
- **No EVM RPC.** Everything needed to build a signature comes off the quote or the session — the
  authorization nonce and the approve nonce are both carried in the quote descriptor. The SDK never
  opens a node connection.
- **Trusted values are pinned, not accepted.** Chain id, gateway and approve-delegate are checked
  against client-side constants before anything is signed, so a compromised backend cannot get a
  signature over an address of its choosing. See `TrustedValues`.
- **Guards run before the key is touched.** A quote that failed the server's safety verdict, is over
  the effective cap, or no longer has enough of its submit window left is refused at
  `prepare_order()` — not after a signature exists.
- **Typed errors.** Every wire error kind maps to an exception class chosen for what the caller
  should *do* about it, so retry-vs-fatal is a type, not a string match.
- **Sync first.** Built on `httpx.Client`.

Session token rides in the `x-opcode-session` header, held in memory only.

## Install

```sh
uv sync
```

## Usage

```python
from opcode_sdk import OpcodeClient, KeystoreSigner, EnvPassword, WalletKind, TrustedValues

client = OpcodeClient("https://app.opcode.fi", chain_id=1)
signer = KeystoreSigner("wallet.json", EnvPassword("OPCODE_KEYSTORE_PASSWORD"))

prep = client.auth.prepare_login(signer.address)
client.auth.submit_login(prep, signer.sign_typed_data(prep.signable))

quote = client.trade.quote(token_in, token_out, amount_in, wallet=WalletKind.privy)
order = client.trade.prepare_order(quote, trusted=TrustedValues.mainnet())
sigs = {
    k: (signer.sign_authorization(s) if k == "authorization" else signer.sign_typed_data(s))
    for k, s in order.signables.items()
    if s is not None
}
res = client.trade.submit_order(order, sigs)
```

`OpcodeWallet(client, signer)` wraps these into one-call flows (`login`, `accept_terms`, `trade`,
`wait_for_order`) and re-logs in automatically on a 401.

`WalletKind.privy` is the default and the kind an agent wants: it is the only one that always
returns the gasless `approve` grant, on any token. `injected` and `unknown` return `permit` or
`self_approve` instead — a wallet-side or user-paid on-chain step the SDK cannot produce, and
`prepare_order()` refuses them with `GrantMethodUnsupported` before touching the key.

## Custody

`KeystoreSigner` reads a standard V3 eth-keystore and is the default. Two things are hooked
separately, so you can replace either without the other:

- **`PasswordSource`** — where the password comes from. `EnvPassword`, `FilePassword`,
  `PromptPassword`, or your own callable.
- **`UnlockGate`** — whether a given signature is allowed to happen at all. `AlwaysUnlocked`,
  `ConfirmEachSignature`, `CachedConfirmation`, or your own. The gate is handed a `SignRequest`
  describing what is about to be signed, and raises `UnlockDenied` to refuse.

For any other custody (HSM, remote signer, MPC, hardware) implement the `Signer` protocol —
`sign_typed_data` and `sign_authorization` — and pass it anywhere a signer is taken. Nothing in the
SDK reaches for a key on its own.

An EIP-7702 authorization is called out as its own `SignKind`: it delegates the EOA's code and is
not something a gasless client can undo, so a gate can treat it differently from an ordinary order
signature. `CachedConfirmation` never caches it.

## Surfaces

`client.auth`, `.onboarding`, `.market`, `.account`, `.compliance`, `.explorer`, `.trade`, `.feed`,
`.recipients`.

Buys, sells and withdrawals all go through the same quote-and-sign path — a withdrawal is a
same-token swap to another receiver. Paying an address other than the signer requires that address
to be in the recipient book first (`client.recipients`), and the book's entries are re-verified
locally against your own signature rather than trusted as returned.

Membership and terms status are read from `GET /api/session`; there are no standalone
`/invite/status`, `/terms/status` or `/session_limits` endpoints.

`tradable_now()` answers whether an asset can be traded right now from the catalog entry plus market
status. It is a client-side read of side-collapsed data and cannot see a per-side halt; a quote is
the only authority.

## MCP server

```sh
export OPCODE_KEYSTORE=~/path/to/wallet.json
export OPCODE_KEYSTORE_PASSWORD_FILE=~/path/to/wallet.pass   # or OPCODE_KEYSTORE_PASSWORD
uv run opcode-mcp
```

One wallet per process — there is no wallet selector, so an agent cannot be talked into paying
from the wrong account. Two wallets means two servers.

**Value-moving tools are two calls, and the human approves a bound rather than a price.**
`opcode_place_order` signs nothing; it freezes an intent and returns a handle plus a summary to
show a person. That summary states a *minimum received*, which the chain enforces. When
`opcode_confirm_order` runs it takes a fresh quote, requires it to clear the frozen minimum, and
only then signs. So the 60-second signing window is entered and left entirely machine-side, and a
human who takes their time cannot be punished for it.

An approval is single-use and expires. Failures raise, so the protocol marks them `isError`, and
each carries a `retry` discriminant (`never` / `after` / `requote` / `fix_input` / `human`) so an
agent can tell a fatal from a transient without parsing prose.

`opcode_withdraw` requires the destination to already be in the recipient book — vetting an address
and paying it are separate decisions, each with their own approval.

## Tests

```sh
uv run pytest
```

The suite pins the EIP-712 digests, the order `meta` bit packing, the grant plan, and the
tradability gate. It makes no network calls.