Skip to main content
Glama
nexus-xyz

nexus-exchange-mcp

Official
by nexus-xyz
README.md
# Nexus Exchange MCP Server

[![License](https://img.shields.io/badge/license-MIT%2FApache--2.0-blue.svg)](#license)

An [MCP](https://modelcontextprotocol.io) server that exposes the Nexus
Exchange API as tools an AI agent (Claude Desktop / Claude Code) can call to
read market data and place trades.

It talks to the real, public exchange gateway. Market-data and demo tools work
with zero configuration; account and trading tools use HMAC API credentials
read from environment variables.

## What works today

Every tool sends the spec's bare path (`/orders`) to one REST base: the spec's
`/v1` base on the public testnet host (`https://api.testnet.nexus.xyz/v1`), and
the origin on a bare indexer such as `local`. The bridge tools are the one
exception while the pinned spec lacks their bare twins. See
"REST base and paths" below.

| Tool                             | Status                                          | Endpoint (surface)                      |
| -------------------------------- | ----------------------------------------------- | --------------------------------------- |
| `fetch_markets_summary`          | ✅ Live (public)                                | `GET /markets/summary`                  |
| `fetch_markets`                  | ✅ Live (public)                                | `GET /markets`                          |
| `fetch_ticker`                   | ✅ Live (public)                                | `GET /markets/{id}/ticker`              |
| `fetch_tickers`                  | ✅ Live (public)                                | `GET /tickers`                          |
| `fetch_order_book`               | ✅ Live (public)                                | `GET /markets/{id}/orderbook`           |
| `fetch_mark_price`               | ✅ Live (public)                                | `GET /markets/{id}/mark-price`          |
| `fetch_market_status`            | ✅ Live (public)                                | `GET /markets/{id}/status`              |
| `fetch_trades`                   | ✅ Live (public)                                | `GET /markets/{id}/trades`              |
| `fetch_ohlcv`                    | ✅ Live (public)                                | `GET /markets/{id}/candles`             |
| `fetch_funding_rate_history`     | ✅ Live (public)                                | `GET /markets/{id}/funding`             |
| `fetch_funding_samples`          | ✅ Live (public)                                | `GET /markets/{id}/funding-samples`     |
| `fetch_market_risk_params`       | ✅ Live (public)                                | `GET /markets/{id}/risk-params`         |
| `fetch_stats`                    | ✅ Live (public)                                | `GET /stats`                            |
| `fetch_stats_history`            | ✅ Live (public)                                | `GET /stats/history`                    |
| `get_demo_account`               | ✅ Live (public)                                | `GET /demo/account`                     |
| `get_demo_positions`             | ✅ Live (public)                                | `GET /demo/positions`                   |
| `get_demo_orders`                | ✅ Live (public)                                | `GET /demo/orders`                      |
| `fetch_balance`                  | ✅ Live (needs key)                             | `GET /account`                          |
| `fetch_account_summary`          | ✅ Live (needs key)                             | `GET /account/summary`                  |
| `fetch_account_state`            | ✅ Live (needs key)                             | `GET /account/state`                    |
| `fetch_trading_fees`             | ✅ Live (needs key)                             | `GET /account/fees`                     |
| `fetch_portfolio_history`        | ✅ Live (needs key)                             | `GET /account/portfolio-history`        |
| `fetch_equity_history`           | ✅ Live (needs key)                             | `GET /account/equity-history`           |
| `fetch_positions`                | ✅ Live (needs key)                             | `GET /positions`                        |
| `fetch_positions_history`        | ✅ Live (needs key)                             | `GET /positions/closed`                 |
| `fetch_open_orders`              | ✅ Live (needs key)                             | `GET /orders`                           |
| `fetch_order`                    | ✅ Live (needs key)                             | `GET /orders/{id}`                      |
| `fetch_orders`                   | ✅ Live (needs key)                             | `GET /orders/history`                   |
| `fetch_my_trades`                | ✅ Live (needs key)                             | `GET /fills`                            |
| `fetch_funding_history`          | ✅ Live (needs key)                             | `GET /funding`                          |
| `fetch_withdrawals`              | ✅ Live (needs key)                             | `GET /withdrawals`                      |
| `fetch_deposits`                 | ✅ Live (needs key)                             | `GET /deposits`                         |
| `fetch_rate_limit_status`        | ✅ Live (needs key)                             | `GET /account/rate-limit`               |
| `fetch_cancel_on_disconnect`     | ✅ Live (needs key)                             | `GET /account/cancel-on-disconnect`     |
| `set_cancel_on_disconnect`       | ✅ Live (needs key)                             | `PUT /account/cancel-on-disconnect`     |
| `fetch_adl_history`              | ✅ Live (needs key)                             | `GET /account/{addr}/adl-history`       |
| `fetch_adl_events`               | ✅ Live (needs key)                             | `GET /markets/{id}/adl-events`          |
| `create_order`                   | ✅ Live (needs key)                             | `POST /orders`                          |
| `create_orders`                  | ✅ Live (needs key)                             | `POST /orders/batch`                    |
| `edit_order`                     | ✅ Live (needs key)                             | `PATCH /orders/{id}`                    |
| `preview_order`                  | ✅ Live (needs key)                             | `POST /orders/preview`                  |
| `cancel_order`                   | ✅ Live (needs key)                             | `DELETE /orders/{id}`                   |
| `cancel_all_orders`              | ✅ Live (needs key)                             | `DELETE /orders`                        |
| `deposit`                        | ✅ Live (needs key)                             | `POST /account/deposit`                 |
| `create_deposit`                 | ✅ Live (needs key)                             | `POST /deposits`                        |
| `claim_credit`                   | ✅ Live (needs key)                             | `POST /account/credit`                  |
| `claim_faucet`                   | ✅ Live (needs key)                             | `POST /faucet`                          |
| `add_margin`                     | ✅ Live (needs key)                             | `POST /account/margin`                  |
| `fetch_bridge_assets`            | ✅ Live (public)                                | `GET /api/v1/bridge/assets`             |
| `create_bridge_deposit_address`  | ⛔ Deprecated — route not served (ENG-11460)    | `POST /api/v1/bridge/deposit-addresses` |
| `list_bridge_deposit_addresses`  | ⛔ Deprecated — route not served (ENG-11460)    | `GET /api/v1/bridge/deposit-addresses`  |
| `fetch_bridge_deposits`          | ✅ Live (needs key)                             | `GET /api/v1/bridge/deposits`           |
| `fetch_bridge_deposit`           | ✅ Live (needs key)                             | `GET /api/v1/bridge/deposits/{id}`      |
| `create_bridge_wallet_challenge` | ✅ Live (needs key)                             | `POST /api/v1/bridge/wallets/challenge` |
| `register_bridge_wallet`         | ✅ Live (needs key + EIP-191 sig or own wallet) | `POST /api/v1/bridge/wallets`           |
| `list_bridge_wallets`            | ✅ Live (needs key)                             | `GET /api/v1/bridge/wallets`            |
| `fetch_agents`                   | ✅ Live (needs key)                             | `GET /agents`                           |
| `register_agent`                 | ✅ Live (EIP-712 signature or own wallet)       | `POST /agents/register`                 |
| `revoke_agent`                   | ✅ Live (EIP-712 signature or own wallet)       | `DELETE /agents/{addr}`                 |
| `create_wallet`                  | ✅ Live (stdio, play funds only)                | none (local key)                        |
| `login`                          | ✅ Live (EIP-191 signature or own wallet)       | `POST /auth/login`                      |
| `fetch_api_keys`                 | ✅ Live (needs session token)                   | `GET /keys`                             |
| `create_api_key`                 | ✅ Live (needs session token)                   | `POST /keys`                            |
| `delete_api_key`                 | ✅ Live (needs session token)                   | `DELETE /keys/{key_id}`                 |
| `create_ws_token`                | ✅ Live (needs key)                             | `POST /ws/token`                        |
| `create_ws_token_legacy`         | ✅ Live (needs key)                             | `POST /ws-tokens`                       |
| `fetch_status`                   | ✅ Live (public)                                | `GET /status`                           |
| `fetch_tiers`                    | 🔒 Admin (opt-in, see below)                    | `GET /admin/tiers`                      |
| `set_tier`                       | 🔒 Admin (opt-in, see below)                    | `PUT /admin/tiers`                      |
| `delete_tier`                    | 🔒 Admin (opt-in, see below)                    | `DELETE /admin/tiers/{addr}`            |
| `get_deposit_target`             | 🚧 Pending — server-side endpoint not built yet | none yet                                |

`get_deposit_target` is wired into the agent flow but returns a clear
`not_yet_available` message rather than faking a result. The bridge
deposit-address tools (`create_bridge_deposit_address` /
`list_bridge_deposit_addresses`) were meant to supersede it, but they are
deprecated: no server implements `/api/v1/bridge/deposit-addresses` (its design
was cancelled with ENG-11460), so every call fails.

### Tool names

Each tool is named `snake_case` of the spec `operationId` it calls, with the
direct-indexer `V1` suffix dropped (`fetchOrderBookV1` → `fetch_order_book`),
so the name an agent picks matches the operation and the other SDKs (R2.25,
ENG-17742). `scripts/check_spec_drift.py` enforces it.

The previous names (`list_markets`, `get_ticker`, `place_order`, …) stay
registered for one minor release as deprecated aliases: each forwards to the
same handler, and its description starts with ``Deprecated: use `<new name>`.``
The full map is `DEPRECATED_ALIASES` in
[`src/tools/index.ts`](./src/tools/index.ts). Two changes are not plain renames:

- `list_markets` keeps returning the markets **summary**
  (`GET /markets/summary`), so it aliases `fetch_markets_summary`. The
  static specs (`GET /markets`, formerly `list_market_specs`) are
  `fetch_markets`.
- `cancel_order` now cancels one order only. The account-wide cancel it used to
  do with `cancel_all: true` is `cancel_all_orders`, which requires
  `confirm: true`.

### REST base and paths

Per **EDR-006** (ENG-17186, ENG-18323) every request is the spec's bare path
under one REST base: sent as `/v1/orders` on the public testnet host, signed as
`/orders`.

- **One base.** `https://api.testnet.nexus.xyz/v1` on testnet, and the bare
  origin (`http://localhost:9090`) on an indexer that serves at its root, like
  `local`. The `/v1` is the published prefix the edge strips before the indexer
  sees the request, so copy the base whole rather than trimming it to the
  hostname: the bare host routes nothing and answers 404. A
  `NEXUS_EXCHANGE_API_URL` that still ends in `/api/exchange` is accepted and
  normalized, so it cannot double up. Which host that is comes from the
  [network axis](#networks), and a named network keeps its own gateway path when
  `NEXUS_EXCHANGE_API_URL` only redirects the host.
- **HMAC signs the bare path** (`/orders`). The base's own prefix is not signed
  over, so the signed path and the wire path differ by that prefix; see
  [Authentication](#authentication).
- **The bridge is the one exception.** The pinned spec (v0.8.1) declares the
  bridge operations only under `/api/v1/bridge/*`, so those tools still send
  `/v1/api/v1/bridge/...` and sign `/api/v1/bridge/...`, which routes and
  verifies. They move to bare paths once a published spec carries the bare
  twins (ENG-18318 added them upstream).
- **`cancel_order` requires `market_id`**. `cancel_all_orders` takes an
  optional `market_id` to scope a mass-cancel to one market.

### API-surface coverage

**71 registered tools** (plus 48 deprecated aliases, see
[Tool names](#tool-names)) covering **66 spec operations** of Exchange API spec
**v0.8.1**. Those are two different numbers and neither substitutes for the
other: an alias calls the same operation as its canonical tool, and two tools
call none. The operation count is the figure comparable with the rs / py / cli
SDK manifests; the tool count is MCP's own axis and must never be reported as a
coverage figure. [`docs/coverage-unit.md`](./docs/coverage-unit.md) records that
decision and how it is enforced.

66 of the **68** distinct operations, or 66 of the **101** the spec literally
documents — the spec lists most operations twice, once on the legacy gateway
route and once on its `/api/v1` alias, and each aliased pair is one tool.

The operation list is not hand-counted: [`endpoints.txt`](./endpoints.txt) is
generated from the per-tool `ops` declarations in `src/tools/index.ts` and
verified against the pinned spec on every PR by `scripts/check_spec_drift.py`
(see [Spec drift](#spec-drift)).

The pin bump (ENG-6038) was pin-only — it advanced `.api-version` v0.6.2 →
v0.7.1 without mapping the surface those releases had added. ENG-6136 then
exposed those additions as tools, and ENG-6461 advanced the pin to v0.7.2
together with the portfolio-parity surface it added. ENG-9342 then advanced it
to v0.7.3, pin-only again; the three routes that release added are mapped here
(ENG-9202, below), leaving only the field-level half of ENG-9636. ENG-10482 then
advanced the pin to v0.8.1, which added and removed no route: both tags document
the same 101 operations, and all 92 documented 2xx bodies stay
`application/json`. That is why neither pin bump moved the counts by itself —
not that the two releases were empty. v0.8.x did change schemas on routes this
server already maps, and the additions it does not expose are listed after the
list below. Below, the spec version each addition shipped in is noted:

- **Portfolio parity** (v0.7.2) — `fetch_portfolio_history`
  (`GET /account/portfolio-history`: equity + PnL + volume series over a
  `day`/`week`/`month`/`all` window), `fetch_account_state`
  (`GET /account/state`: summary + open positions from one coherent
  read), and `fetch_trading_fees` (`GET /account/fees`: effective
  maker/taker bps, tier, rolling 30d volume, discounts). The same release
  enriched the `Position` schema — `notional_value`, `margin_used`, `roe`,
  `max_leverage`, and `leverage`, each nullable with a `<field>_error`
  companion, plus the always-present `funding_paid` (no `_error` companion:
  its `"0"` is a real zero, not unknown) — and added
  `withdrawable` to the portfolio summary; those are response-shape additions
  on already-mapped routes, so they change no route count — the tools that
  return them (`fetch_balance`, `fetch_positions`, `fetch_account_state`,
  `fetch_account_summary`) call them out in their descriptions instead.
- **Account cancel-on-disconnect** (v0.7.1) — `fetch_cancel_on_disconnect` /
  `set_cancel_on_disconnect` (`GET` / `PUT /account/cancel-on-disconnect`).
- **`/api/v1/bridge` Phase A** (v0.7.1) — `fetch_bridge_assets` (public catalog),
  `create_bridge_deposit_address`, `list_bridge_deposit_addresses`,
  `fetch_bridge_deposits`, and `fetch_bridge_deposit` (five operations).
- **Registered withdrawal wallets** (v0.7.3, ENG-8902) —
  `create_bridge_wallet_challenge`, `register_bridge_wallet`, and
  `list_bridge_wallets` (`POST /api/v1/bridge/wallets/challenge`,
  `POST` / `GET /api/v1/bridge/wallets`). Unlike a schema-only addition these
  are `/api/v1`-native with no legacy alias, so they raised the denominator:
  the v0.7.2 → v0.7.3 pin bump (ENG-9342) moved it from 65 distinct operations
  to 68 while coverage stayed at 63. Mapping them here closes that gap.
  Registration is two-step and stateless — the challenge returns a message
  bound to the account and the address, and `register_bridge_wallet` echoes it
  back with the wallet's EIP-191 signature over it. The challenge is **not** a
  nonce: until it expires the same signature can be resubmitted, which is a
  no-op because it only re-registers the same address for the same account.
  The account holds one wallet in this cut and it cannot be replaced — a
  different address is refused with `409 wallet_already_registered` — so
  `register_bridge_wallet` is funds-guarded and additionally requires
  `confirm: true`.
- **Conditional order types** (v0.7.0) — `create_order` / `create_orders` /
  `preview_order` now map all six conditional `order_type`s in addition to
  `limit` / `market`: stop-loss (`stop_limit` / `stop_market`), take-profit
  (`take_profit_limit` / `take_profit_market`), and trailing (`trailing_stop` /
  `trailing_limit`), via the `trigger_price`, `trailing_offset_bps`, and
  `limit_offset_bps` fields. These are a schema addition on the already-mapped
  order endpoint, so they change no route count — which is why the pin bump's
  operation-count metric never surfaced the gap.

Every operation this server can map is now mapped (ENG-9202). The **2**
remaining are unmapped **by design** — the WebSocket **upgrade** endpoints
`GET /ws` and `GET /stream`: a request/response MCP tool cannot hold a
streaming socket open, so the server instead mints the auth token
(`create_ws_token` / `create_ws_token_legacy`) the caller uses to connect to them
directly. That is a decision, not a backlog item, and it is recorded as such
in `NON_SPEC_TARGETS`' neighbouring note in `scripts/check_spec_drift.py`; the
checker keeps printing them so the exclusion stays visible rather than
becoming invisible once it is deliberate.

The v0.7.2 `cursor` query parameter (ENG-5506) is now exposed on the five
paginated list tools — see "Pagination" below. It adds no route, so the operation
count above is unaffected.

Two optional request fields are documented but **not** exposed, and neither
affects the operation count. v0.7.3 documents `max_slippage_bps` on
`create_order`, `create_orders`, and `preview_order` (ENG-7550) — note the
engine has always accepted and enforced it (the fill VWAP is bounded against the
mid captured at submission, and the remainder cancels when the cap would be
crossed); v0.7.3 puts it on the public contract rather than introducing it, so
this is exposure work, not a new capability. It adds no route, so ENG-9202
did not cover it; it remains the field-level half of ENG-9636.

v0.8.0 documents the optional `stp` field on those same three order tools — the
public exposure ENG-5022 asked for. It is opt-in self-trade prevention, one of
`CancelNewest`, `CancelOldest` or `DecrementAndCancel`, where omitted or `null`
means self-matching is allowed: that is the default and the industry-standard
behaviour, so a caller who never sets it is unaffected. The engine has always
accepted it, so this too is exposure work rather than a new capability. The same
release added two read-only companions on `Order` that this server does not
surface either — `stp` echoes back the mode an order was placed with, and
`cancellation_reason` reports why a terminal order was cancelled (`null`, a bare
string, or a single-key object such as `{"Stp": "CancelOldest"}`). None of the
three adds a route. ENG-9636 is scoped to the v0.7.3 surface and does **not**
cover them; the v0.8.x surface needs its own follow-up.

One already-mapped route did change its response body: v0.8.1 repointed both
`funding-samples` operations from `FundingSample` to a new `FundingPremiumSample`
carrying only `timestamp` and `premium_index`. `FundingSample` is otherwise
unchanged and still serves `GET /markets/{market_id}/funding`, so this is a
schema repoint rather than a property removal — but the effect on the
`funding-samples` body is that `funding_rate`, `mark_price` and `oracle_price`
are gone from it. `fetch_funding_samples` describes the series and never named
those keys, and nothing in this repo reads them, so no tool description drifted.

Reconciling the liveness surface: v0.7.0 removed the standalone `/health` and
`/ready` routes from the public contract (only `/status` remains), so the former
`get_health` / `get_readiness` tools — which called routes the pinned spec no
longer documents — were **dropped** in favour of the surviving
`fetch_status` (`/status`).

### Pagination

Five list tools are cursor-paginated (spec v0.7.2, ENG-5506):

| Tool                      | Endpoint                      | `limit` max |
| ------------------------- | ----------------------------- | ----------- |
| `fetch_trades`            | `GET /markets/{id}/trades`    | 1000        |
| `fetch_my_trades`         | `GET /fills`                  | 1000        |
| `fetch_orders`            | `GET /orders/history`         | 500         |
| `fetch_positions_history` | `GET /positions/closed`       | 200         |
| `fetch_equity_history`    | `GET /account/equity-history` | 720         |

Each returns an **envelope**, not a bare array:

```json
{ "items": [ … ], "next_cursor": "opaque-token" }
```

The agent drives the loop: call once with no `cursor`, then call again with
`cursor: <previous next_cursor>`, and **stop when `next_cursor` is null**. Server
state rides in the `X-Next-Cursor` response header, which the server sends only
while more results exist — its absence is the documented end-of-results signal,
not an error. An empty `items` array with a non-null `next_cursor` is _not_ the
end either: a sparse page still has pages behind it.

`limit` bounds **one page**, not the total, and is validated against that
endpoint's own maximum (table above) before the request, so an out-of-schema
value is never signed or forwarded. The maxima are not interchangeable — in
particular `fetch_portfolio_history`'s `limit` cap of 366 is that endpoint's alone
and it takes no `cursor` at all.

If the upstream ever returns the _same_ cursor it was given, paging cannot
advance. Handing that token back would put an agent in an unbounded tool-call
loop, so the tool forces `next_cursor` to null and adds a `pagination_error`
saying the results are incomplete — a stop, but explicitly not "end of history".

These parameters were in the spec ahead of the indexer, which may not yet emit
`X-Next-Cursor` (ENG-5506). That degrades safely: with no header every response
is `next_cursor: null`, i.e. exactly the pre-pagination single-page behaviour.

### Authorization tiers

- **Public** — no credentials.
- **HMAC (key)**: account reads, trading, agent/funding actions. Uses
  `NEXUS_EXCHANGE_API_KEY` / `NEXUS_EXCHANGE_API_SECRET`, or the key
  `create_api_key` just made (see [Stored credentials](#stored-credentials)).
  The testnet host verifies it as your own key; see
  [Authentication](#authentication).
- **Wallet signature**: `login` (EIP-191), `register_agent` and
  `revoke_agent` (EIP-712), and `register_bridge_wallet` (EIP-191) carry a
  wallet signature. `revoke_agent` accepts nothing else: no HMAC key, session
  or agent key. Pass one made in the wallet as `signature`, or, on a
  play-funds target only, omit it and the stdio server signs with its own
  wallet (see [Local wallet](#local-wallet)).
  The server never holds a key for real or undeclared funds, and the hosted
  server never holds one at all.
- **Session token** — the `*_api_key` tools authenticate with a Bearer session
  token from `login`. The stdio server keeps it for the session and saves it,
  so nothing needs copying into the environment (see
  [Stored credentials](#stored-credentials)); `NEXUS_EXCHANGE_SESSION_TOKEN`
  still works.
- **Admin (opt-in)** — `fetch_tiers` / `set_tier` / `delete_tier` use the
  operator admin secret and mutate other accounts' fee tiers. They are **not
  registered** unless `NEXUS_EXCHANGE_ENABLE_ADMIN_TOOLS=1` is set (and
  `NEXUS_EXCHANGE_ADMIN_SECRET` provided). Never enable these on an untrusted
  agent surface.

Destructive tools (`revoke_agent`, `delete_api_key`, `delete_tier`, and
`cancel_all_orders`) require an explicit `confirm: true` flag so a stray call
can't do damage by accident.

## Quick start

The server is published to npm as
[`@nexus-xyz/exchange-mcp`](https://www.npmjs.com/package/@nexus-xyz/exchange-mcp),
so it runs with no clone and no build step. Register it with Claude Code in one
line:

```bash
claude mcp add nexus -- npx -y @nexus-xyz/exchange-mcp
```

Or launch the stdio server directly (`npx` fetches the package on first run):

```bash
npx -y @nexus-xyz/exchange-mcp
```

It waits on stdio for an MCP client and is meant to be launched by that client
(see [Claude Desktop config](#claude-desktop-config) below) rather than run by
hand. Market-data and demo tools work with zero configuration; see
[Environment variables](#environment-variables) to enable account/trading tools.

Prefer to run from a checkout — for development, or to use the smoke check? See
[Development](#development).

## Full circle on testnet

With nothing configured, an agent on the stdio server can go from no account to
a closed position on `testnet` (play funds) through tools alone. Each step is
one tool call:

| Step | Tool             | Arguments                                                                      | What happens                                                                              |
| ---- | ---------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- |
| 1    | `create_wallet`  | none                                                                           | Makes a wallet key and saves it (see [Local wallet](#local-wallet)). Returns the address. |
| 2    | `login`          | none                                                                           | Signs the login message with that wallet and keeps the session token.                     |
| 3    | `create_api_key` | none                                                                           | Mints an HMAC key with that token and keeps it, so the account tools work from here on.   |
| 4    | `claim_faucet`   | none                                                                           | Credits synthetic USDX. `fetch_balance` shows it.                                         |
| 5    | `create_order`   | `market_id: "BTC-USDX-PERP"`, `side: "buy"`, `type: "market"`, `size: "0.001"` | Opens a small position. `fetch_positions` shows it.                                       |
| 6    | `create_order`   | the same with `side: "sell"`, `reduce_only: true`                              | Closes it (`reduce_only` never flips it). `fetch_positions` is empty again.               |

Nothing is copied into the environment and the server is not restarted (see
[Stored credentials](#stored-credentials)). `create_wallet` refuses when this
network already has a wallet; `confirm: true` replaces it and the old key is
lost. `claim_faucet` has a per-wallet cooldown and cap, and `claim_credit` is
the per-key daily allowance.

[`examples/full-circle.mjs`](./examples/full-circle.mjs) runs these steps
against a throwaway config directory, so your own Nexus config is left alone.

## Environment variables

Copy `.env.example` and set as needed. Only trading/account tools need
credentials — never commit real secrets.

| Variable                            | Required                | Purpose                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| ----------------------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NEXUS_EXCHANGE_NETWORK`            | No                      | Network to target: `testnet` (default, play funds), `local`, `mainnet`, or `custom`. See [Networks](#networks). An unrecognized value is an error, never a default.                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `NEXUS_EXCHANGE_API_URL`            | With `custom`           | Explicit **base** override — scheme + host, plus the REST base path the deployment serves the spec under (testnet's `/v1`); every request is the spec's bare path under it plus the gateway path. Wins over `NEXUS_EXCHANGE_NETWORK`. Defaults to the selected network's host. A legacy value ending in `/api/exchange` is accepted and normalized. **On its own it is deprecated** — it names a host without declaring whose money is behind it; use `NEXUS_EXCHANGE_NETWORK=custom` with the bundle below. Alongside a named network it is not deprecated: that target already declared its funds. |
| `NEXUS_EXCHANGE_NETWORK_LABEL`      | With `custom`           | Name for a custom stage. Restricted to `[A-Za-z0-9._-]`, max 64 — the Nexus clients namespace stored credentials by it. See [A custom stage](#a-custom-stage).                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `NEXUS_EXCHANGE_FUNDS`              | With `custom`           | Whose money is behind the URL: `real`, `play` or `unknown`. **No default.** Until it is declared, the tools that cannot be undone refuse to run.                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `NEXUS_EXCHANGE_FAUCET`             | No                      | Set to `1` if a custom stage has a faucet. Separate from funds and absent until declared: `claim_faucet` / `claim_credit` need play funds **and** a faucet.                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `NEXUS_EXCHANGE_GATEWAY_PATH`       | No                      | Where a custom stage hangs off its host: `/api/exchange` (default) or `/` for a bare indexer serving at its root. Places **both** surfaces since ENG-6221, so a wrong value 404s every tool, not only the legacy ones. Read only with `NEXUS_EXCHANGE_NETWORK=custom` — refused on its own.                                                                                                                                                                                                                                                                                                          |
| `NEXUS_EXCHANGE_AGENT_PRIVATE_KEY`  | For account/trade tools | Private key (hex) of a registered agent. Signs every account/trade call with `agentAuth` and wins over the HMAC pair. Can trade, cannot withdraw. See [Agent keys](#agent-keys-recommended-for-ai-agents).                                                                                                                                                                                                                                                                                                                                                                                           |
| `NEXUS_EXCHANGE_API_KEY`            | For account/trade tools | HMAC API key id (`x-api-key`).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `NEXUS_EXCHANGE_API_SECRET`         | For account/trade tools | HMAC secret (hex).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `NEXUS_EXCHANGE_PRIVATE_KEY`        | No                      | Wallet private key (hex) the stdio server signs with when `signature` is omitted. Wins over a stored `private_key`. Read only on a play-funds target, ignored elsewhere and by the hosted server. See [Local wallet](#local-wallet).                                                                                                                                                                                                                                                                                                                                                                 |
| `NEXUS_EXCHANGE_SESSION_TOKEN`      | For `*_api_key` tools   | Bearer session token from `login` (`POST /auth/login`).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `NEXUS_EXCHANGE_ADMIN_SECRET`       | For admin tools         | Operator admin secret (`ADMIN_SECRET`). Only with the flag below.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `NEXUS_EXCHANGE_ENABLE_ADMIN_TOOLS` | No                      | Set to `1` to register the admin tier tools. Off by default.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |

### Stored credentials

The stdio server shares its credential file with the Nexus CLI (`nexus`), so a
key made in one works in the other. The file is
`$XDG_CONFIG_HOME/nexus/config.json`, or `~/.config/nexus/config.json` when
`XDG_CONFIG_HOME` is unset (macOS included), with one section per network under
`networks.<network>`, as in the CLI's
[per-network credentials](https://github.com/nexus-xyz/nexus-exchange-cli#per-network-credentials):

```json
{
  "networks": {
    "testnet": {
      "api_key": "nx_...",
      "api_secret": "...",
      "session_token": "..."
    }
  }
}
```

- `login` keeps its session token for the rest of the session and saves it to
  the active network's section, as `nexus auth login` does.
- `create_api_key` does the same with the new key id and secret. Once the
  secret is saved, the result shows `"secret": "[REDACTED]"` and `saved_to`
  names the file. If the save fails, the result carries the secret in full and
  a `save_error`.
- At startup the server reads the active network's section. Environment
  variables win field by field, matching the CLI's order (flags > env > file).
  A file that is not valid JSON stops the server at startup, as it stops the
  CLI, rather than being read as empty and overwritten.

So an agent goes from `login` to `create_api_key` to a trade in one session,
with nothing copied into the environment and no restart, and the key still
works after one.

The file is written `0600` in a `0700` directory, through a temp file and a
rename. Each write holds `.config.json.xlock` across its read, merge and
rename, the lock `nexus` also takes
([concurrent writers](https://github.com/nexus-xyz/nexus-exchange-cli#concurrent-writers)),
so a `nexus` command and the server never overwrite each other's change. The
lock is created exclusively (`O_CREAT|O_EXCL`), holds
`pid=<pid> created_ms=<unix ms>`, and is deleted when the write ends; a writer
that finds it retries for up to 15 s and deletes it once its mtime is over
10 s old. A `nexus` older than the lock does not take it, so the server still
re-reads after each write and reports a field such a write erased as unsaved
(the secret comes back in full). One network's credentials never go into another's section: a bare
`NEXUS_EXCHANGE_API_URL`, and a custom stage whose label spells a network name,
have no section, so what `login` and `create_api_key` return is kept for the
session only and the secret comes back in full. The hosted HTTP server never
reads or writes this file.

### Local wallet

On a play-funds target (testnet, `local`, or a custom stage declared
`NEXUS_EXCHANGE_FUNDS=play`), the stdio server can hold a wallet key of its
own, so an agent can make an account with nothing signed elsewhere:
`create_wallet`, then `login` with no `signature`, then `create_api_key`.

- `create_wallet` generates a secp256k1 key and saves it as `private_key` in
  the active network's section of the file above, then uses it for the rest of
  the session. It returns the address and `saved_to`, never the key. If a
  wallet already exists it refuses unless `confirm: true`, which overwrites the
  old key and keeps it nowhere.
- To use a key you already have, set `NEXUS_EXCHANGE_PRIVATE_KEY`. It wins over
  the file, and `create_wallet` will not replace it.
- With a wallet held, `login`, `register_agent`, `revoke_agent` and
  `register_bridge_wallet` sign for themselves when `signature` is omitted. An
  explicit `signature` is still accepted everywhere. `login` signs only its
  fixed message, `register_bridge_wallet` only for the held wallet's own
  address, and `register_agent` and `revoke_agent` only on testnet and `local`
  (their signatures are bound to the server's network name, which a custom
  stage does not declare).
- On mainnet, and on a custom stage whose funds are `real` or `unknown`,
  `create_wallet` and self-signing refuse before any request, and a key in the
  environment or the file is not even loaded. No tool ever returns the key.
- The `nexus` CLI shares the file. A CLI release without
  [nexus-exchange-cli#95](https://github.com/nexus-xyz/nexus-exchange-cli/pull/95)
  drops `private_key` the next time it writes the file (for example on
  `nexus auth login`), so update the CLI before using both.

## Agent safety modes

Two switches limit what an agent can do, for trying an agent out or giving it
market access without trading rights (ENG-20366). Each one is an env var or a
flag in the stdio server's `args`. The hosted HTTP server reads only the env var.

| Mode      | Env var                    | Flag          | What the agent gets                                                                                                  |
| --------- | -------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------- |
| Read-only | `NEXUS_EXCHANGE_READ_ONLY` | `--read-only` | Only tools that change nothing. Placing, amending, cancelling, deposits, logins and key changes are gone.            |
| Paper     | `NEXUS_EXCHANGE_PAPER`     | `--paper`     | Read-only, plus simulated `create_order`, `create_orders`, `cancel_order`, `cancel_all_orders`, `fetch_open_orders`. |

```json
{ "command": "npx", "args": ["-y", "@nexus-xyz/exchange-mcp", "--paper"] }
```

Read-only keeps a tool only when its `readOnlyHint` annotation is true, so the
set is exactly what `tools/list` labels as read-only. Every tool carries MCP
annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`); one that
calls anything but a `GET` has to declare them or the server refuses to start.
`preview_order` and the two WebSocket-token tools are `POST`s that count as
read-only: they change no account state.

Paper mode fetches the market's public order book when an order is placed and
walks it. A market order takes levels until it fills or the book runs out. A
limit order fills only the levels its price crosses, and what is left rests in
an in-memory paper book for the life of the process (`IOC` cancels it, `FOK`
fills all or nothing, `PostOnly` is rejected if it would cross). Every
response says `"simulated": true`. It is deliberately simple. It does **not**
simulate margin or balance checks, fees, funding, liquidation or positions, and
a resting paper order never fills later, even when the market trades through
it. Only `limit` and `market` orders are simulated. Nothing in paper mode
places, amends or cancels a real order.

## Networks

The target is chosen on a **network** axis — whose money is behind it — not a
release channel. `NEXUS_EXCHANGE_NETWORK` takes `testnet`, `mainnet`, `local` or
`custom`; the map for the three named networks lives in one place,
[`src/networks.ts`](./src/networks.ts), copied from the spec's
`x-nexus-networks` extension.

| Network   | Funds                    | Faucet         | Target today                                               |
| --------- | ------------------------ | -------------- | ---------------------------------------------------------- |
| `testnet` | Play (synthetic USDX)    | Yes            | `https://api.testnet.nexus.xyz/v1` — **the default**       |
| `local`   | Play (whatever you hold) | Yes            | `http://localhost:9090`                                    |
| `mainnet` | **Real money**           | No             | No reachable host yet — selecting it is an error           |
| `custom`  | You declare it           | You declare it | The URL you supply — see [A custom stage](#a-custom-stage) |

**Nothing changes if you set nothing.** The default resolves to exactly the host
this server has always used.

**`mainnet` deliberately does not work yet.** Its host `api.nexus.xyz` has no DNS
(ENG-8155) and the pinned spec maps no operation onto its `/v1` base, so any URL
built for it would be a guess — on the one network where a guess moves real
money. It fails with an explanation instead. To target it once it is live, set
`NEXUS_EXCHANGE_API_URL` explicitly **alongside** `NEXUS_EXCHANGE_NETWORK=mainnet`
— the network declares the funds, so the URL is only redirecting its host and
stays the sanctioned way to reach real funds before the durable host exists.

Three rules this implements, all from the spec extension:

- **Hosts are never interpolated from the network name.** Mainnet is off-pattern
  on purpose — `api.nexus.xyz`, not `api.mainnet.nexus.xyz` — so
  `api.{network}.nexus.xyz` would resolve for every environment that can be
  tested and fail only on real funds. Every host is a named literal.
- **An unrecognized network is treated as real funds.** A typo is an error, never
  a fallback to play money. `local` is likewise never a fallback for a public
  host that fails to resolve — succeeding quietly against localhost would hide a
  misconfigured client.
- **Credentials never cross networks.** Session tokens, HMAC keys and agent
  registrations are minted per network and are invalid on any other. Switching
  network means switching credentials; never carry a signature or a nonce across.

### A custom stage

A deployment that is not one of the three networks — a private stage, a sandbox,
an indexer on your own machine — is a **custom target**: it carries the same
descriptor a named network does (label, host, funds, faucet, gateway shape), and
this package ships no hostname for any of them. You supply it.

```bash
NEXUS_EXCHANGE_NETWORK=custom
NEXUS_EXCHANGE_API_URL=https://exchange.example.com   # the stage's origin
NEXUS_EXCHANGE_NETWORK_LABEL=dev                      # a name for it
NEXUS_EXCHANGE_FUNDS=play                             # real | play | unknown
NEXUS_EXCHANGE_FAUCET=1                               # only if it has one
NEXUS_EXCHANGE_GATEWAY_PATH=/                         # bare indexer; omit for /api/exchange
```

Four rules, and they are the same four in every Nexus client:

- **`custom` carries the whole bundle, not just a URL.** A URL alone is what
  makes a client report play-funds guardrails while pointed at a real-funds host.
- **Funds are caller-declared, tri-state, and have no default.** `real`, `play`,
  or `unknown`. A staging deployment of mainnet is real-funds-shaped, so
  defaulting to `play` would make every guardrail lie in the direction that costs
  money, and defaulting to `real` would make a dev stage unusable.
- **A faucet is separate from funds, and absent until declared.** "Not real
  money" does not imply "has a faucet".
- **The label is restricted to `[A-Za-z0-9._-]` (max 64).** Across the clients it
  is the key stored credentials are namespaced under, so a label that can name a
  path (`../other`, `one/two`) or normalize onto another label is refused.

`custom` is client-side only. It is not a value the API accepts and it does not
appear in the spec's `x-nexus-networks`.

### Undeclared funds refuse the tools that cannot be undone

`NEXUS_EXCHANGE_API_URL` on its own is the **deprecated** legacy shortcut. It
still works — no bundle required, legacy `/api/exchange` suffix still normalized
— and it resolves to a custom target whose funds are **undeclared**. It also has
no deployment shape to read, so it assumes the public-gateway one and the notice
below says so; every request is composed under that gateway path, which
is the one thing about this form that is not byte-identical to what it used to
resolve to. A bare URL pointed at an indexer that serves at its root wants
`NEXUS_EXCHANGE_NETWORK=local` alongside it, or the full bundle. That is not the same as play funds, so these
tools refuse rather than proceed on an assumption:

| Tools                                                                                                                     | Need                                |
| ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- |
| `create_order`, `create_orders`, `edit_order`, `deposit`, `create_deposit`, `add_margin`, `create_bridge_deposit_address` | funds declared `real` **or** `play` |
| `claim_faucet`, `claim_credit`                                                                                            | funds `play` **and** a faucet       |
| `create_wallet`, and `login` / `register_agent` / `revoke_agent` / `register_bridge_wallet` without `signature`           | funds `play`                        |

Everything else is unaffected: every read, `preview_order`, and — deliberately —
`cancel_order`. Blocking a cancel would trap a caller holding open risk, which is
the opposite of a guardrail.

To lift the refusal, say what the target is: select a named network with
`NEXUS_EXCHANGE_NETWORK`, or describe the stage with the bundle above. Declaring
`real` is a legitimate answer and unlocks the irreversible tools — the guard asks
that somebody know, not that the money be play.

Because the bare form cannot answer that question, a server started on it prints
one notice on **stderr** naming the declared form, then runs exactly as before
(a single line, wrapped here):

```
nexus-exchange-mcp: NOTICE: NEXUS_EXCHANGE_API_URL on its own is deprecated and
still works. On its own it also assumes the PUBLIC-GATEWAY shape, so every
route resolves under /api/exchange; for an indexer that serves at its root, add
NEXUS_EXCHANGE_NETWORK=local, or describe the deployment with the full custom
bundle …
```

Nothing is removed, and nothing is written to stdout — that is the JSON-RPC
channel on the stdio surface, and a stray line there would corrupt the protocol.

### Release channels are a URL, not a network

`beta` / `staging` are deployments of testnet, not a third pool of money, so they
are no longer enum values. Describe them as a custom stage instead:

```bash
NEXUS_EXCHANGE_NETWORK=custom
NEXUS_EXCHANGE_API_URL=https://staging.example.com   # the deployment's origin
NEXUS_EXCHANGE_NETWORK_LABEL=staging
NEXUS_EXCHANGE_FUNDS=play                            # a testnet deployment holds play funds
```

The URL overrides the network map and is validated either way (http(s) only, no
embedded `user:password@`, no query or fragment, since the base is concatenated
with a request path). Plaintext `http` to a non-loopback host warns on stderr:
HMAC over http exposes the key id and signature in transit — a private stage on
plain http is exactly what that warning is for.

Setting the URL **alone** still works and is the deprecated shortcut: it leaves
the funds undeclared, which is why it is a `custom` target with no bundle rather
than a mechanism of its own, and why the tools that cannot be undone refuse on
it. Both are documented in [A custom stage](#a-custom-stage).

### WebSocket targets

`create_ws_token` and `create_ws_token_legacy` now return `ws_endpoint` alongside the
token, so a caller is no longer handed a 60-second credential with no address to
spend it at. For a named network the endpoints are the spec's published
`ws_url` for that network (`x-nexus-networks`, copied into `src/networks.ts`),
which is the spec's REST base with the scheme swapped:

```
wss://api.testnet.nexus.xyz/v1/ws      # authenticated, connect with ?token=…
wss://api.testnet.nexus.xyz/v1/stream  # public market data
```

The `/v1` prefix is required: the bare host routes no WebSocket path and answers
`404` (ENG-17132). Mainnet has the same shape on `api.nexus.xyz`, which does not
resolve yet (no DNS, ENG-15183). With an explicit `NEXUS_EXCHANGE_API_URL` (or a
custom stage) the endpoints derive from that gateway base instead, since `/ws`,
`/stream`, `/ws/token` and `/ws-tokens` carry no per-path `servers` override in
the spec. On `local` there is no prefix at all — `ws://localhost:9090/ws` —
because the indexer serves those routes at its root.

## API version

<!-- api-version-sync:start -->

Currently targets Exchange API spec **`v0.8.1`**.

<!-- api-version-sync:end -->

The pinned version lives in [`.api-version`](./.api-version); the spec itself is
published by
[`nexus-xyz/nexus-exchange-api`](https://github.com/nexus-xyz/nexus-exchange-api).
This repo does not vendor a copy — the checks below fetch the pinned release. The
line above is bot-managed; everything around it is human-owned.

Three separate things watch the pin, and they answer different questions:

| Check           | Question                                                | Where                                 |
| --------------- | ------------------------------------------------------- | ------------------------------------- |
| `spec-drift`    | Does the tool surface still match the spec it **pins**? | `.github/workflows/spec-drift.yml`    |
| `drift` (in CI) | Is the pin **behind** the latest release?               | `.github/workflows/ci.yml`            |
| `spec-autobump` | A newer spec released — is the delta breaking?          | `.github/workflows/spec-autobump.yml` |

`spec-autobump` (daily cron, `repository_dispatch` from the api repo, or manual
dispatch) classifies the pin advance with **oasdiff** and opens a PR touching only
`.api-version` and the managed line above, labelled `spec-autobump` or
`breaking · needs-SDK-update`. It never merges: `allow_auto_merge` is disabled on
this repo, so the workflow probes the setting and says so in the PR body rather
than calling `gh pr merge --auto` and reporting success over a no-op. It
supersedes the old poll-only `api-version-sync` workflow, which had no
classification step.

#### Spec drift

`spec-drift` is the verification half, and it runs on **every** PR — including the
autobump's own, where the pin _is_ the change. It enforces three invariants:

1. every operation in `endpoints.txt` exists in the pinned spec;
2. `endpoints.txt` matches the per-tool `ops` declarations byte-for-byte (it is a
   generated artifact, not a hand-maintained list);
3. each tool's declared `ops` match the operations its handler actually requests.

All three key an operation on `METHOD /path` (placeholder names normalized), never
on `operationId`. Ids are not stable across releases: v0.8.0 swapped
`createWsToken` and `createWsTokenLegacy` between `POST /ws/token` and
`POST /ws-tokens` — changing no route, and nothing this server calls, but a guard
keyed on ids would have broken on it anyway.

```bash
npm run spec:drift        # verify against the pinned spec
npm run spec:drift:write  # regenerate endpoints.txt after adding operations
npm run spec:drift:test   # self-test: prove the checker goes red when defeated
```

Adding a tool without declaring what it calls is a type error, so the mapping
cannot be skipped. See [`docs/coverage-unit.md`](./docs/coverage-unit.md).

Every upstream request also sends this pin as an `X-Nexus-Api-Version: <tag>`
header (e.g. `X-Nexus-Api-Version: v0.8.1`), alongside a normalized
`User-Agent: nexus-exchange-mcp/<version>`, so the exchange edge can attribute
and segment usage by client and by the contract version this server targets.
The header value is the server's own compiled-against tag — it is baked in at
build time (a test keeps it equal to [`.api-version`](./.api-version)), so it is
never taken from caller input.

## Authentication

### Agent keys (recommended for AI agents)

An agent key is a secp256k1 key a wallet authorizes to trade for it
(`POST /agents/register`). It can place, amend and cancel orders and read the
account, but it **cannot withdraw**, transfer, or manage agents. That makes it
the credential to hand an AI agent: if the model, its transcript or its host
leaks the key, nobody can move funds out with it.

Set `NEXUS_EXCHANGE_AGENT_PRIVATE_KEY` (or send `X-Nexus-Agent-Private-Key` to
the [hosted server](#hosted-http-server-remote-mcp)). Every account and trade
tool then signs with the `agentAuth` scheme the SDKs use, and the agent key wins
over an HMAC pair when both are set:

```text
keccak256(<METHOD>\n<path>\n<query>\n<sha256hex(body)>\n<timestamp_ms>\n<nonce>)
```

signed with the agent key (recoverable secp256k1, no EIP-191 prefix) and sent
as `x-signature` alongside `x-agent`, `x-timestamp` and `x-nonce`. The path is
the same bare path the HMAC scheme signs.

To get one, generate a key (any 32 random bytes, e.g. `openssl rand -hex 32`)
and register its address with your wallet: `nexus agents register` in the
[Nexus CLI](https://github.com/nexus-xyz/nexus-exchange-cli), or the
`register_agent` tool here. The server requires each
agent's nonce to rise on every write, so this server sends one agent-signed
write at a time; run one server per agent key.

### HMAC API keys

Signed requests use the same canonical HMAC-SHA256 scheme the indexer verifies
(`backend/services/indexer/src/auth.rs`):

```text
<timestamp>\n<METHOD>\n<path>\n<query>\n<sha256hex(body)>
```

signed with the hex-decoded secret and sent as `x-signature` alongside
`x-api-key` and `x-timestamp`.

The signed path is the spec's bare path (e.g. `/orders`), and for the bridge
tools `/api/v1/bridge/...`. The base's own prefix is not signed over. On a bare
indexer (`NEXUS_EXCHANGE_NETWORK=local`) the two coincide, so the client signs
exactly what it sends. On the public testnet host they differ: the wire path is
`/v1/orders` while the signature covers `/orders`, and the route strips `/v1`
before the indexer checks (`apps-prod-testnet.yaml`: "transport prefixes are
stripped before auth"). If signed calls 401 on a prefixed host and the same key
works against a bare indexer, that stripping is the thing to check.

The testnet host verifies the caller's own HMAC. A bad signature is answered
`401 UNAUTHORIZED`, and a valid key acts as the account that minted it: its own
balance, faucet credit, orders and positions. So trading as yourself needs no
special gateway, and an agent can make its own account there with tools alone
(see [Full circle on testnet](#full-circle-on-testnet)). `local` (the
`http://localhost:9090` from the exchange `docker-compose`) verifies client HMAC
the same way.

The one exception is the retired legacy gateway at
`exchange.nexus.xyz/api/exchange`, which no network targets since ENG-8869 (it
now answers `500`). It proxied authenticated requests and signed them with the
site's own frontend key, so per-caller HMAC was not honored and calls resolved
to the site account. A `custom` stage still behind such a proxy behaves the same
way.

## Claude Desktop config

Add this to your Claude Desktop config
(`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):

```json
{
  "mcpServers": {
    "nexus-exchange": {
      "command": "npx",
      "args": ["-y", "@nexus-xyz/exchange-mcp"],
      "env": {
        "NEXUS_EXCHANGE_NETWORK": "testnet"
      }
    }
  }
}
```

Running from a local checkout instead? Use `"command": "node"` with
`"args": ["/ABSOLUTE/PATH/TO/nexus-exchange-mcp/dist/index.js"]` (after
`npm run build`).

`testnet` is the default, so the `env` block above is optional — it is spelled
out because naming the network is what declares the funds. To reach a deployment
this package ships no host for, use the `custom` bundle rather than
`NEXUS_EXCHANGE_API_URL` alone:

```json
"env": {
  "NEXUS_EXCHANGE_NETWORK": "custom",
  "NEXUS_EXCHANGE_API_URL": "https://exchange.example.com",
  "NEXUS_EXCHANGE_NETWORK_LABEL": "dev",
  "NEXUS_EXCHANGE_FUNDS": "play"
}
```

To enable trading, add `NEXUS_EXCHANGE_API_KEY` / `NEXUS_EXCHANGE_API_SECRET` to
the `env` block, or on testnet let the agent make its own key (see
[Full circle on testnet](#full-circle-on-testnet)).

## Demo script

1. Add the config above, restart Claude Desktop, and confirm `nexus-exchange`
   appears in the tools list.
2. Ask: "Show me the BTC market on Nexus" — Claude calls `fetch_markets_summary` /
   `fetch_ticker` and reports the live BTC-USDX-PERP price.
3. Ask: "What's in the demo account and its open positions?" — Claude calls
   `get_demo_account` and `get_demo_positions` against the live exchange.

## Hosted HTTP server (remote MCP)

The stdio server above runs locally and holds your API key on your machine. The
hosted **Streamable HTTP** server is the remote front door: it lets a trader
add Nexus as a remote MCP server without running any key-holding software
locally.

```bash
# from the published package (no clone):
npx -p @nexus-xyz/exchange-mcp nexus-exchange-mcp-http

# or from a checkout:
npm run build
npm run start:http   # listens on :8080, MCP endpoint at /mcp, probe at /healthz
```

Behind a TLS-terminating ingress this is the public endpoint
`https://mcp.exchange.nexus.xyz/mcp`. A client adds it with:

```bash
claude mcp add --transport http nexus https://mcp.exchange.nexus.xyz/mcp
```

It exposes the **same tool surface** as the stdio server — both transports
register the identical `ToolDef[]` from `src/tools/` via
`createServerForClient` in `src/server.ts`, so the tools never drift. The
transport is the SDK's `StreamableHTTPServerTransport` in stateful mode (one
MCP session per `mcp-session-id`), which also serves the SSE fallback stream
for server→client messages. Hosted traffic keeps the same
`nexus-exchange-mcp/<version>` `User-Agent` as the stdio CLI but appends a
` (http)` comment (`nexus-exchange-mcp/<version> (http)`) so usage attributes
to the hosted MCP in the dashboard while still segmenting under one product and
version.

### Authentication (MVP — no OAuth yet)

> **OAuth 2.1 is out of scope for this MVP** (tracked under the hardening work,
> ENG-3598, and scoped-key minting, ENG-3486). Until that lands, the hosted
> server takes the caller's existing Exchange HMAC credential as request
> headers, captured once at session initialize and reused for the session:
>
> ```text
> X-Nexus-Api-Key:    <hmac key id>
> X-Nexus-Api-Secret: <hmac secret, hex>
> ```
>
> or, recommended, a trade-only [agent key](#agent-keys-recommended-for-ai-agents)
> that cannot withdraw:
>
> ```text
> X-Nexus-Agent-Private-Key: <agent private key, hex>
> ```
>
> These are deliberately **not** named `x-api-key` / `x-signature` (the
> upstream gateway's own headers) to avoid confusion. With no credential
> headers a session still serves public market-data tools, and every
> authenticated tool refuses with an error naming the two headers.
>
> **The hosted server never uses server-env credentials** (ENG-4359).
> `NEXUS_EXCHANGE_API_KEY`, `NEXUS_EXCHANGE_API_SECRET`,
> `NEXUS_EXCHANGE_AGENT_PRIVATE_KEY`, `NEXUS_EXCHANGE_SESSION_TOKEN`,
> `NEXUS_EXCHANGE_ADMIN_SECRET` and `NEXUS_EXCHANGE_PRIVATE_KEY` are ignored in
> HTTP mode, and so is the
> [stored-credentials](#stored-credentials) file,
> so a caller that sends no headers can never trade as the server's account.
> Admin tools are never exposed over HTTP. The stdio server still reads them
> from the environment as before, then from that file.
>
> Header passthrough is the simplest defensible MVP, but the long-term answer
> is OAuth-minted scoped (trade-not-withdraw) keys so the caller never hands us
> a raw secret. See ENG-3598 / ENG-3486.

### Limits and session lifetime

| Variable                       | Default   | Meaning                                                                            |
| ------------------------------ | --------- | ---------------------------------------------------------------------------------- |
| `MCP_HTTP_SESSION_IDLE_TTL_MS` | `1800000` | A session with no request for this long is closed and its client dropped (30 min). |
| `MCP_HTTP_RATE_LIMIT_BURST`    | `60`      | Token-bucket size per client IP on `/mcp`.                                         |
| `MCP_HTTP_RATE_LIMIT_PER_SEC`  | `2`       | Tokens refilled per second per client IP.                                          |
| `MCP_HTTP_TRUSTED_PROXY_HOPS`  | `0`       | How many proxies in front of the server append to `X-Forwarded-For`.               |
| `MCP_HTTP_MAX_BODY_BYTES`      | `1048576` | Largest `initialize` POST body read; past it the server answers `413` (1 MiB).     |

Sessions live in process memory, so a restart or redeploy forgets them too. A
session id the server doesn't hold, evicted or lost, answers `404` (the MCP
Streamable HTTP spec's signal), and the client reconnects with a fresh
`initialize`. A request with no session id is a `400`. Only requests count as
activity: an open SSE `GET` stream does not keep a session alive. A client over
the limit gets `429` with a `Retry-After` header (seconds). `/healthz` is not
limited.

The limiter is in memory and per process, so the effective ceiling is per
replica: N replicas allow N times the configured rate. An ingress-level limit
is the global answer.

**Client IP.** With `MCP_HTTP_TRUSTED_PROXY_HOPS=0` (the default) the limiter
keys on the TCP peer and ignores `X-Forwarded-For`, because a client can set
that header to anything and would otherwise dodge the limit. Behind an ingress
the peer is the ingress itself, so every caller shares one bucket: set the
variable to the number of proxies that append to `X-Forwarded-For` (1 for a
single reverse proxy, 2 for a Google Cloud load balancer, which appends both
the client and its own address). The limiter then takes the Nth entry from the
right, the one the outermost trusted proxy saw. Setting it higher than the
real hop count lets a client spoof its IP again.

## Development

Clone and build from source:

```bash
git clone https://github.com/nexus-xyz/nexus-exchange-mcp.git
cd nexus-exchange-mcp
npm install
npm run build
npm start            # runs the stdio MCP server from the local build
```

Then the usual checks:

```bash
npm run format     # prettier --write
npm run lint       # eslint
npm run typecheck  # tsc --noEmit
npm test           # unit tests (HMAC scheme, arg mapping, schemas)
npm run test:coverage # unit tests + coverage (text/lcov/json-summary); CI emits the %
npm run spec:drift # tool surface vs. the pinned spec (see "Spec drift" above)
```

The smoke check runs end-to-end against a live deployment. Against a local
indexer, name the network too, so the target keeps the bare-origin shape:

```bash
NEXUS_EXCHANGE_NETWORK=local NEXUS_EXCHANGE_API_URL=http://localhost:9090 npm run smoke
```

The public testnet host works as well. Name the network alongside it: the
deprecated bare-URL form assumes the retired public-gateway shape and would
append `/api/exchange` to a base that already carries `/v1`.

```bash
NEXUS_EXCHANGE_NETWORK=testnet NEXUS_EXCHANGE_API_URL=https://api.testnet.nexus.xyz/v1 npm run smoke
```

Expected output ends with `fetch_markets_summary OK -> N markets`.

It **requires an explicit `NEXUS_EXCHANGE_API_URL`** and has no default
(ENG-8092) — `fetch_markets_summary` is a read, and reads are never funds-guarded. The
URL names the host; the **gateway path** decides where every request lands under it
(ENG-6221), and that comes from the network. So `NEXUS_EXCHANGE_API_URL` alone
resolves `…/api/exchange/…` and prints the bare-URL deprecation notice on
stderr before proceeding, while `NEXUS_EXCHANGE_NETWORK=local` alongside it
serves the spec's paths at the origin and prints no notice — the network declared
the target, so the URL only redirected the host. Pointing a bare
`NEXUS_EXCHANGE_API_URL` at an indexer that serves at its root sends every
route under `/api/exchange`, where a bare indexer serves nothing; name the
network, or describe the stage with the full `custom` bundle (which is where
`NEXUS_EXCHANGE_GATEWAY_PATH=/` is read — it is refused on its own). If the
target answers with HTML rather than JSON, on a 404 or on a 200, the check says
so by name and exits non-zero; it never reports a passing run for a body it
could not read as market-summary JSON.

## License

Dual-licensed under [MIT](./LICENSE-MIT) or [Apache-2.0](./LICENSE-APACHE), at
your option — same as the other Nexus Exchange SDKs.

TDQS

B3.2/5.0

Scored across 115 tools

Disambiguation1/5

Roughly half the surface consists of deprecated aliases that duplicate a live tool one-to-one (get_balance/fetch_balance, get_orderbook/fetch_order_book, place_order/create_order, amend_order/edit_order, list_deposits/fetch_deposits, etc.), so an agent must constantly resolve 'which of these two identical tools do I call?'. On top of that, several tools are admitted dead ends that always fail (create_bridge_deposit_address, list_bridge_deposit_addresses, get_deposit_target), and get_deposit vs create_deposit vs deposit_collateral add further near-miss overlap.

Naming Consistency2/5

Two competing conventions coexist: the modern set uses fetch_*/create_*/edit_*/cancel_*/register_* while the deprecated set uses get_*/list_*/place_*/amend_*/adjust_*. Within each family the verb_noun pattern is readable, but mixing get_/fetch_ and place_/create_ across the same resource is exactly the inconsistency the rubric penalizes.

Tool Count1/5

115 tools is an extreme mismatch for any single server. Even after removing the ~45 deprecated aliases, the live surface is 60+ tools, which far exceeds a well-scoped set and forces the agent to carry a large mental model of near-duplicates.

Completeness4/5

Domain coverage is actually broad and strong: markets/tickers/orderbook/OHLCV/funding, full order lifecycle (create, batch, preview, edit, cancel, cancel-all), account/positions/PnL/fees/history, deposits/withdrawals/bridge, API keys, agent keys, wallets, and WS tokens. The main gap is that a few advertised bridge/deposit-target tools have no working server implementation, leaving genuine dead ends.

Maintenance

ActivityActive
ResponsivenessNo issues