AI Agents (MCP)
aspens-mcp is a Model Context Protocol
server over the Aspens SDK. It lets an AI agent read a venue, place and
cancel orders, and move funds in and out, within per-market limits that
you set in a config file. The agent cannot change those limits.
It speaks MCP over stdio only. Your agent harness starts it as a child
process on the same machine, and it opens no network listener. It trades
through the same gRPC API as aspens-cli, with the same keys, so it
works against any Aspens Market Stack.
1. Install
aspens-mcp lives in the SDK repository.
It is built from source:
cargo install --locked --git https://github.com/aspensprotocol/sdk aspens-mcp2. Configure
The server reads one TOML file, ~/.aspens-mcp/aspens-mcp.toml by
default. Start from
aspens-mcp.example.toml:
stack_url = "https://<stack>/..." # the venue's gRPC endpoint
env_file = "~/.aspens-mcp/.env" # trader keys and RPC URLs, mode 0600
state_dir = "~/.aspens-mcp" # the ledger lives here
allow_withdraw = false # true registers the withdraw tool
settle_address_allowlist = [] # addresses an order may settle to, besides your own
[deposits]
enabled = true
# max_per_call = { "base-sepolia:0x…" = "1000" } # keyed "network:token-address"
# One block per tradable market. Every field is required.
[markets."<market_id>"]
max_order_qty = "10" # base a single order may carry
max_daily_qty = "100" # base across buys and sells, per UTC day
max_order_quote = "25000" # quote a single buy may commit
max_daily_quote = "250000" # quote committed by all buys, per UTC day
max_deviation_bps = 100 # how far an order may price through the best opposite price| Setting | Meaning |
|---|---|
stack_url | The venue's gRPC endpoint. |
env_file | The trader keys, in the variables aspens-cli reads: TRADER_PRIVKEY (EVM and Tron), TRADER_PRIVKEY_SOLANA, and ASPENS_RPC_URL_<NETWORK> per chain. The file must exist with mode 0600, or the server refuses to start. The config file itself holds no secrets. |
state_dir | The server's ledger: daily usage, the orders it sent, and halts. Usage and halts survive a restart. One server per state_dir; a second refuses to start. |
allow_withdraw | false by default. The withdraw tool does not exist for the agent unless this is true. |
settle_address_allowlist | Empty by default, so an order settles to the trader's own addresses. An agent may name another settle address only if it is listed here. |
[deposits] | With enabled = false every deposit call is refused. max_per_call caps a single deposit of a token. |
[markets."<market_id>"] | The limits for one market. A market with no block is not tradable through this server. |
Amounts are decimal strings in the market's units. Daily usage counts submissions, not fills: an order's quantity and quote count against the UTC day once it is sent, even if it is cancelled later.
The config is read at startup only. To change a limit, edit the file and restart the server.
Pinning the venue
An optional [venue] block refuses orders, deposits and withdrawals
when the venue is not the one you expect:
[venue]
expected_contracts = { "base-sepolia" = "0x…" }With the block set, every network an action touches needs an entry
(both legs of a market), and the action is refused if GetConfig
reports a different trading contract for it. Without the block, the
server trusts whatever answers at stack_url; account_status says
so.
[venue] also accepts attestation_mrtd. Do not set it yet: a pinned
measurement alone cannot verify a report, so the server fails closed
and refuses every order, deposit and withdrawal.
3. Connect an agent
Any MCP client that can spawn a stdio server works. Run
aspens-mcp --config <path>; serve is the default subcommand.
With Claude Code:
claude mcp add aspens -- aspens-mcp --config ~/.aspens-mcp/aspens-mcp.tomlFor a client configured through JSON:
{
"mcpServers": {
"aspens": {
"command": "aspens-mcp",
"args": ["--config", "/home/you/.aspens-mcp/aspens-mcp.toml"]
}
}
}Logs go to stderr. Stdout carries only JSON-RPC.
4. Tools
The server registers fifteen tools, and withdraw as a sixteenth when
allow_withdraw = true. Amounts in arguments and results are decimal
strings; results carry the integer beside each one in a …Raw field.
Read
| Tool | Arguments | Returns |
|---|---|---|
venue_config | none | The venue's markets (legs, decimals, pair decimals, tradable here or not), chains and tokens. |
top_of_book | market_id | Best bid and best ask. A side is null when it is empty. |
orderbook | market_id, levels (default 20) | Visible depth, aggregated by price, best first. |
balances | none | Per token and chain: the wallet balance, the amount available on the venue, the amount locked, and native gas. |
my_orders | status: open (default), closed or all | The orders this server sent, with the venue's record of each: state, filled and remaining quantity, fills. |
get_order | order_id | One of those orders, with its fills. |
my_fills | market_id | Your fills on a market, as maker and as taker, newest last. Covers open orders and orders closed in the last 7 days. |
account_status | none | Your addresses per curve, which keys are loaded, whether the venue is reachable, whether trading is halted and why, and whether the venue is pinned. |
limits | none | The configured limits per market, with today's usage and remaining budget. |
venue_attestation | none | The venue's attestation report for a fresh nonce, and whether it was verified. |
Write
Every write tool checks every limit before anything is signed. A refusal names each rule that blocked it. Unknown arguments are an error, never ignored.
| Tool | Arguments | Effect |
|---|---|---|
place_limit_order | market_id, side, qty, price; optional post_only, hidden, settle_base_address, settle_quote_address | Places a limit order. |
place_marketable_order | market_id, side, qty; optional slippage_bps (default 50) | Takes liquidity with a limit order priced at the best opposite price, moved against you by slippage_bps. |
cancel_order | order_id; market_id and side only for an order this server did not send | Cancels an order. One that is not live is reported as already_gone. Allowed while halted. |
deposit | network, token (symbol), amount | Moves a token from your wallet into the venue. |
withdraw | network, token (symbol), amount | Moves a token from the venue to your own address on that network. A call can take about two minutes. |
halt | optional reason | Stops new orders, deposits and withdrawals. |
There are no raw market orders and no named-counterparty fills. Those parameters are absent from the tool schemas.
5. What the limits enforce
- Size per order. A buy never commits more quote than
max_order_quote, and no order carries more base thanmax_order_qty. - Size per day.
max_daily_qtyandmax_daily_quotecap what is submitted in one UTC day. The usage record is written to disk before the order is signed. - Price. An order may price at most
max_deviation_bpsthrough the venue's best opposite price at that moment. Forplace_marketable_order, slippage beyond that is refused. - Markets. Only markets with a
[markets]block are tradable. - Destinations. Withdrawals go to the trader's own address only. Settle addresses must be on the allowlist.
The price check reads the venue's own book. On a thin book someone else can set the best bid or ask, and the check follows them. The size caps are what hold, so set them to what you can afford to lose to a bad fill. A resting order can also fill later at its own price after the market has moved; that is the ordinary risk of a limit order.
6. Halt and resume
halt is a kill switch the agent can pull and cannot release. It is
written to the ledger, so it survives a restart. Cancels stay allowed
while halted, since a cancel only reduces exposure.
To lift a halt, stop the server and run:
aspens-mcp resume --config ~/.aspens-mcp/aspens-mcp.tomlresume refuses while a server holds the same state_dir.
7. Order states
my_orders and get_order report the venue's
order status
record for each order:
| State | Meaning |
|---|---|
open, partially_filled, filled, cancelled, expired | As named. |
dropped_by_restart | The venue restarted while the order rested. |
traded | Closed by fills. The venue cannot recover the order's initial size, so no remaining quantity is shown, and its fills have side: null. |
rejected | The venue refused the send. |
unknown | The venue has no record of the order. |
An order's id is derived from the exact order and recorded before it
is signed. If a send gets no answer, the order is reported unknown
under that id, get_order asks the venue about it, and its real state
replaces unknown once the venue reports it. The server never sends
the order a second time.
The venue reports a closed order for 7 days.
Reading order status
Order status is an owner-signed read. At startup the server makes an Ed25519 delegate key, keeps it in memory only, and has each trader wallet sign a read-only delegation to it for 24 hours. A leaked delegate key can read the owner's order statuses until that delegation expires, and there is no early revocation. It can never trade, cancel or withdraw.
Hidden orders
place_limit_order takes hidden: true. A hidden order stays out of
the public book and its fills print with your side redacted. It goes
through every limit a visible order does, and get_order, my_orders
and my_fills still show it to you.
8. Behaviour to know about
- Order status can trail a send by a moment.
get_orderstraight afterplace_limit_ordermay not yet show a taker fill that the send response did. haltandcancel_ordercan wait up to about 60 s behind an order whose send is still in flight.- When the client disconnects mid-send, the server waits up to 70 s
for the send to finish and record its outcome before it exits. If
the process dies first, the next start lists that order as
unknownand still counts it against the day's limits. - A venue restart that changes a market's pair decimals refuses
orders on that market until
aspens-mcprestarts. - Amounts with more decimal places than the market or token allows are rejected, not rounded.