aster-agent-gateway 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aster_agent_gateway-0.1.0.dist-info/METADATA +189 -0
- aster_agent_gateway-0.1.0.dist-info/RECORD +10 -0
- aster_agent_gateway-0.1.0.dist-info/WHEEL +4 -0
- aster_agent_gateway-0.1.0.dist-info/entry_points.txt +2 -0
- aster_agent_gateway-0.1.0.dist-info/licenses/LICENSE +21 -0
- aster_mcp/API_NOTES.md +99 -0
- aster_mcp/__init__.py +14 -0
- aster_mcp/chain.py +287 -0
- aster_mcp/rest.py +438 -0
- aster_mcp/server.py +1171 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: aster-agent-gateway
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP gateway for AI agents to Aster DEX public data: futures and spot market overviews, order books, klines, funding and TradFi-perp screeners, vault deposit flows, public account views. Read-only, keyless.
|
|
5
|
+
Project-URL: Homepage, https://github.com/alekskram/aster-agent-gateway
|
|
6
|
+
Project-URL: Repository, https://github.com/alekskram/aster-agent-gateway
|
|
7
|
+
Project-URL: Issues, https://github.com/alekskram/aster-agent-gateway/issues
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai-agents,aster,asterdex,defi,funding,mcp,perps
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: fastmcp>=3.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: hatchling; extra == 'dev'
|
|
23
|
+
Requires-Dist: pip-audit; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# aster-agent-gateway
|
|
29
|
+
|
|
30
|
+
[](https://github.com/alekskram/aster-agent-gateway/actions/workflows/tests.yml)
|
|
31
|
+
[](https://pypi.org/project/aster-agent-gateway/)
|
|
32
|
+
[](LICENSE)
|
|
33
|
+
[](pyproject.toml)
|
|
34
|
+
|
|
35
|
+
An MCP (Model Context Protocol) server that gives AI agents read-only,
|
|
36
|
+
keyless access to **Aster DEX** public data - ~580 futures symbols
|
|
37
|
+
(including 24/7 TradFi perps: metals, equity indices, energy,
|
|
38
|
+
treasuries), ~68 spot pairs, funding, order books, klines, vault
|
|
39
|
+
deposit flows and tapi account views. No API keys, no auth, no
|
|
40
|
+
signing, no writes: every tool reads public endpoints only
|
|
41
|
+
(`fapi.asterdex.com/fapi/v3`, `sapi.asterdex.com/api/v3`,
|
|
42
|
+
`tapi.asterdex.com/info`, `api.mainnet-beta.solana.com`, plus optional
|
|
43
|
+
EVM RPCs), cached and rate-limited so an enthusiastic agent cannot
|
|
44
|
+
hammer the upstream.
|
|
45
|
+
|
|
46
|
+
## Use cases
|
|
47
|
+
|
|
48
|
+
- **Screen 731 funding rates in one call** — ranked by annualized rate with funding regime and mark/index spread; the 793% outliers are visible instantly
|
|
49
|
+
- **Trade TradFi 24/7** — metals, equity indices, energy, treasuries perps next to crypto, one consistent API
|
|
50
|
+
- **Follow the vault money** — deposit flows per vault: who is parking capital where
|
|
51
|
+
- **Spot index dislocations** — mark vs index divergence ranking across the whole board
|
|
52
|
+
- **Morning scan** — market overview + funding overview + OI snapshot, three cheap calls
|
|
53
|
+
|
|
54
|
+
Full walkthroughs: [examples/use-cases.md](examples/use-cases.md).
|
|
55
|
+
|
|
56
|
+
## Quickstart
|
|
57
|
+
|
|
58
|
+
stdio (default, for local agents):
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
uvx aster-agent-gateway
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
or from a checkout:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
git clone https://github.com/alekskram/aster-agent-gateway
|
|
68
|
+
cd aster-agent-gateway
|
|
69
|
+
uv sync
|
|
70
|
+
uv run aster-agent-gateway
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Claude Desktop / Cursor config:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"mcpServers": {
|
|
78
|
+
"aster": {
|
|
79
|
+
"command": "uvx",
|
|
80
|
+
"args": ["--from",
|
|
81
|
+
"git+https://github.com/alekskram/aster-agent-gateway",
|
|
82
|
+
"aster-agent-gateway"]
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Hosted form - streamable HTTP on port **8904**:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
uv run aster-agent-gateway --http # 127.0.0.1:8904
|
|
92
|
+
curl http://127.0.0.1:8904/health # -> {"ok": true, "service": "aster-agent-gateway", ...}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
<details>
|
|
96
|
+
<summary><b>Codex</b> (~/.codex/config.toml)</summary>
|
|
97
|
+
|
|
98
|
+
```toml
|
|
99
|
+
[mcp_servers.aster]
|
|
100
|
+
command = "uvx"
|
|
101
|
+
args = ["aster-agent-gateway"]
|
|
102
|
+
```
|
|
103
|
+
</details>
|
|
104
|
+
|
|
105
|
+
<details>
|
|
106
|
+
<summary><b>ZCode</b> — register the server (copy-paste)</summary>
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# 1) start the gateway (keep it running)
|
|
110
|
+
uvx aster-agent-gateway --http --port 8904 &
|
|
111
|
+
|
|
112
|
+
# 2) register it (merges into ~/.zcode/cli/config.json)
|
|
113
|
+
python3 - <<'PY'
|
|
114
|
+
import json, os
|
|
115
|
+
p = os.path.expanduser("~/.zcode/cli/config.json")
|
|
116
|
+
os.makedirs(os.path.dirname(p), exist_ok=True)
|
|
117
|
+
cfg = json.load(open(p)) if os.path.exists(p) else {}
|
|
118
|
+
cfg.setdefault("mcp", {}).setdefault("servers", {})["aster"] = {
|
|
119
|
+
"type": "http", "url": "http://127.0.0.1:8904/mcp"}
|
|
120
|
+
json.dump(cfg, open(p, "w"), indent=2)
|
|
121
|
+
print("aster-agent-gateway registered:", p)
|
|
122
|
+
PY
|
|
123
|
+
```
|
|
124
|
+
</details>
|
|
125
|
+
|
|
126
|
+
Hosted form — streamable HTTP on port **8904**:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
uvx aster-agent-gateway --http
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Tools
|
|
133
|
+
|
|
134
|
+
All 13 tools are read-only (annotated `readOnlyHint: true,
|
|
135
|
+
destructiveHint: false`).
|
|
136
|
+
|
|
137
|
+
| # | Tool | Signature | What it does |
|
|
138
|
+
|---|------|-----------|--------------|
|
|
139
|
+
| 1 | `market_overview` | `market_overview(limit=20, sort="volume")` | Futures panel from ONE ticker/24hr ALL call joined with exchangeInfo: TRADING markets, top volumes, status counts, fresh listings (onboardDate). |
|
|
140
|
+
| 2 | `exchange_symbols` | `exchange_symbols(venue="futures", symbol=None, include_junk=False)` | Symbol universe with filters/precisions incl. MIN_NOTIONAL, stepSize, leverageFilter; TEST*/SETTLING junk filtered by default. |
|
|
141
|
+
| 3 | `order_book` | `order_book(symbol, venue="futures", depth=10)` | fapi depth / sapi api/v3 depth; limit snapped to a priced tier (weight-aware). |
|
|
142
|
+
| 4 | `klines` | `klines(symbol, interval="1h", limit=100, market="futures", price_type="last")` | last/mark/index klines on fapi; spot via sapi. |
|
|
143
|
+
| 5 | `trades` | `trades(symbol, limit=20, venue="futures")` | Fresh keyless trades; spot path probed live (honest error dict if dead). |
|
|
144
|
+
| 6 | `spot_overview` | `spot_overview(limit=20)` | Spot pairs from sapi ticker/24hr + exchangeInfo; TEST* junk filtered. |
|
|
145
|
+
| 7 | `funding_overview` | `funding_overview(limit=20, sort="rate")` | All ~730 rates from premiumIndex + fundingInfo: mixed 1/2/4/8h intervals (flagged), cap/floor, interestRate, nextFundingTime. |
|
|
146
|
+
| 8 | `tradfi_markets` | `tradfi_markets(limit=20, window=None)` | TradFi-perp screener by asset class (metals/equity/energy/treasuries/forex) + `tradfi_crypto_corr` sub-block: local TradFi-vs-BTC correlation from klines. |
|
|
147
|
+
| 9 | `funding_screener` | `funding_screener(top=10, direction="both")` | One-call ranking by annualized funding, premium, mark-index spread + `funding_regime` headroom to cap/floor. |
|
|
148
|
+
| 10 | `oi_snapshot` | `oi_snapshot(symbols=None, top=10)` | Per-symbol openInterest (max 10 symbols/call). No keyless OI history (404) - stated honestly. |
|
|
149
|
+
| 11 | `deposit_flows` | `deposit_flows(chain="all", limit=20)` | Solana vault signatures (keyless) + EVM vault Transfers when `ASTER_EVM_RPC_URL*` set + `deposit_stats` hourly/chain buckets. |
|
|
150
|
+
| 12 | `account_view` | `account_view(address, data="balance")` | tapi `aster_getBalance`/`openOrders`/`userFills` keyless for any address; privacy-empty returns an honest error dict explaining why. |
|
|
151
|
+
| 13 | `mark_index_divergence` | `mark_index_divergence(limit=20)` | mark vs index spread screener from ONE premiumIndex call + markPriceKlines-vs-klines crosscheck on the top 3. |
|
|
152
|
+
|
|
153
|
+
## Rate limits
|
|
154
|
+
|
|
155
|
+
Two REST buckets, locally enforced and weight-aware:
|
|
156
|
+
|
|
157
|
+
- **fapi 2400 weight/min**, **sapi 6000/min** (header
|
|
158
|
+
`X-MBX-USED-WEIGHT-1M` read after every call). Above 80% of a
|
|
159
|
+
bucket the client self-throttles; 429 backs off honoring
|
|
160
|
+
`Retry-After`; a 418 (repeated 429 = IP ban) triggers a 60s refusal
|
|
161
|
+
cooldown. Depth/kline limits snap to priced tiers so callers cannot
|
|
162
|
+
accidentally burn weight.
|
|
163
|
+
- **Solana 10 req/min, tapi 30/min, EVM 20/min** local budgets
|
|
164
|
+
(separate ledgers, fail-fast honest errors).
|
|
165
|
+
|
|
166
|
+
TTL caches: exchangeInfo 3600s, fundingInfo 600s, premiumIndex and
|
|
167
|
+
tickers 15s, depth 5s, klines 60s, trades 10s, openInterest 30s.
|
|
168
|
+
|
|
169
|
+
## Data notes
|
|
170
|
+
|
|
171
|
+
- Every numeric from the API is a STRING upstream; parsed with a
|
|
172
|
+
never-raising helper - `null` always means "not available", never
|
|
173
|
+
zero.
|
|
174
|
+
- Every upstream failure returns an error dict
|
|
175
|
+
`{"error", "source", "reason"}`, never a traceback.
|
|
176
|
+
- **No OI history exists keyless** (`/futures/data/openInterestHist`
|
|
177
|
+
404s) - the gateway says so instead of inventing data.
|
|
178
|
+
- **EVM vault logs** need `ASTER_EVM_RPC_URL` (or per-chain
|
|
179
|
+
`ASTER_EVM_RPC_URL_{BSC,ETH,ARB}`); free public RPCs reject vault
|
|
180
|
+
queries with -32005. Unset -> honest "no RPC configured".
|
|
181
|
+
- **tapi account privacy**: most accounts are private; empty results
|
|
182
|
+
are reported as privacy, not as data.
|
|
183
|
+
- Cached responses carry `age_seconds` / `fetched_at` freshness
|
|
184
|
+
fields.
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
## License
|
|
188
|
+
|
|
189
|
+
MIT.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
aster_mcp/API_NOTES.md,sha256=LDIPy0Tdoktsb8y6jGML_5apzvK-yWKoPXBRRNU2fTE,4885
|
|
2
|
+
aster_mcp/__init__.py,sha256=l12zxeqIENwd4xbClVO1kfAuZ-wmfJIqFZCetiDAfoE,533
|
|
3
|
+
aster_mcp/chain.py,sha256=GMDMow4P1IGTuqdrIx-Ord82ZBWfY8GmKEl54x6fkfM,11256
|
|
4
|
+
aster_mcp/rest.py,sha256=ZrApV4gzKraqsaOYoWYsCrQab3HN_WdImmcQ6S_VHig,16251
|
|
5
|
+
aster_mcp/server.py,sha256=OD3_LxH9Y4ABVwTvb_cNhWwzyelIvT1p-dN18XtZjDo,48059
|
|
6
|
+
aster_agent_gateway-0.1.0.dist-info/METADATA,sha256=aFo1iJ_35Y7PkebdCchPkx8kzwuzzctEWmihmcs2pfA,8558
|
|
7
|
+
aster_agent_gateway-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
8
|
+
aster_agent_gateway-0.1.0.dist-info/entry_points.txt,sha256=VxYNjpiJndfRIL2_0ocomSfJiVVWd_2zw0q65BvrNYM,62
|
|
9
|
+
aster_agent_gateway-0.1.0.dist-info/licenses/LICENSE,sha256=ViMP6VKf71rJhWk_KyCZFUL_8oOL-_Dpq3SRfpO4vJU,1089
|
|
10
|
+
aster_agent_gateway-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 aster-agent-gateway contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
aster_mcp/API_NOTES.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# API notes - Aster public surfaces (as verified 2026-09-05/06)
|
|
2
|
+
|
|
3
|
+
Facts the gateway depends on. Every claim below was checked against the
|
|
4
|
+
live endpoints before v0.1.0 was coded; fixtures in `tests/fixtures/`
|
|
5
|
+
mirror these shapes.
|
|
6
|
+
|
|
7
|
+
## fapi - futures (https://fapi.asterdex.com/fapi/v3/*)
|
|
8
|
+
|
|
9
|
+
- Binance-compatible v3 REST, keyless for market data. ALL numerics
|
|
10
|
+
arrive as STRINGS ("0.0001"); parse with a never-raising `_f()`.
|
|
11
|
+
- **Rate limits: weight-based per IP, 2400 weight/min** on fapi
|
|
12
|
+
(header `X-MBX-USED-WEIGHT-1M` on every response). 429 -> backoff
|
|
13
|
+
honoring `Retry-After`; repeated 429 -> **418 IP ban** (the client
|
|
14
|
+
cools down 60s and refuses calls).
|
|
15
|
+
- `exchangeInfo` (weight 1): ~580 symbols; statuses ~559 TRADING /
|
|
16
|
+
15 SETTLING / 6 PENDING; quotes USDT 568 / USD1 10; symbol rows
|
|
17
|
+
carry `onboardDate`, `contractType`, `status`, `filters`
|
|
18
|
+
(PRICE_FILTER tickSize, LOT_SIZE stepSize, MIN_NOTIONAL,
|
|
19
|
+
LEVERAGE_FILTER).
|
|
20
|
+
- `ticker/24hr` with NO symbol param (weight ~40): ALL ~574 rows in
|
|
21
|
+
ONE call - symbol, lastPrice, priceChangePercent, quoteVolume,
|
|
22
|
+
volume, highPrice, lowPrice, weightedAvgPrice, count.
|
|
23
|
+
- `premiumIndex` no param (weight ~10): ALL ~730 rows - symbol,
|
|
24
|
+
markPrice, indexPrice, lastFundingRate, nextFundingTime,
|
|
25
|
+
interestRate.
|
|
26
|
+
- `fundingInfo` (weight 1): ~730 rows - fundingIntervalHours MIXED
|
|
27
|
+
(1/2/4/8h histogram ~351/3/103/273), cap 0.003-0.03, floor -0.03,
|
|
28
|
+
interestRate.
|
|
29
|
+
- `depth?symbol=&limit=` - weight by limit: 5/10/20/50 = 2, 100 = 5,
|
|
30
|
+
500 = 10, 1000 = 20 (client snaps requests down to a priced tier).
|
|
31
|
+
- `klines` / `markPriceKlines` / `indexPriceKlines` -
|
|
32
|
+
`?symbol=&interval=&limit=`; weight 1 (limit<=100) .. 5.
|
|
33
|
+
- `/trades?symbol=` - fresh keyless trades.
|
|
34
|
+
- `openInterest?symbol=` - per-symbol snapshot ONLY (weight 1).
|
|
35
|
+
**`/futures/data/openInterestHist` returns 404** - no keyless OI
|
|
36
|
+
history exists; the gateway says so instead of inventing data.
|
|
37
|
+
|
|
38
|
+
## sapi - spot (https://sapi.asterdex.com/api/v3/*)
|
|
39
|
+
|
|
40
|
+
- MIGRATION note: the old `/sapi/v1/*` paths **404**; live base is
|
|
41
|
+
`/api/v3/*`.
|
|
42
|
+
- Rate limit 6000 weight/min (separate bucket; same header).
|
|
43
|
+
- `exchangeInfo`: 68 TRADING pairs plus TEST*-named junk symbols -
|
|
44
|
+
filtered by name in this gateway (flag to include).
|
|
45
|
+
- `klines`, `depth`: live 200, keyless.
|
|
46
|
+
- Spot `/trades`: path was uncertain at recon; probed in the smoke
|
|
47
|
+
script - if it 404s the tool degrades to an honest error dict.
|
|
48
|
+
|
|
49
|
+
## tapi - Aster Chain JSON-RPC (https://tapi.asterdex.com/info)
|
|
50
|
+
|
|
51
|
+
- Body `{"jsonrpc":"2.0","id":1,"method":"aster_getBalance","params":
|
|
52
|
+
["0x...", "latest"]}` (PARAMS IS AN ARRAY: address + blockTag; the
|
|
53
|
+
object form `{"userAddress": ...}` returns HTTP 400 - live-verified)
|
|
54
|
+
- answers KEYLESS, but **account privacy hides most accounts**:
|
|
55
|
+
private accounts return a result with only {address,
|
|
56
|
+
accountPrivacy: "enabled"} and no balances (observed on the BSC vault
|
|
57
|
+
address and a burn address). Empty = privacy, not an API failure -
|
|
58
|
+
the gateway returns an honest error-dict explaining why.
|
|
59
|
+
- `aster_openOrders` on a privacy-hidden account returns JSON-RPC
|
|
60
|
+
-32603 (internal error) - also mapped to the privacy degradation
|
|
61
|
+
path.
|
|
62
|
+
- Methods used (read-only): `aster_getBalance`, `aster_openOrders`,
|
|
63
|
+
`aster_userFills` (+ `aster_spotGetBalance` /
|
|
64
|
+
`aster_spotOpenOrders` / `aster_spotUserFills` variants).
|
|
65
|
+
- `eth_*` NOT supported (eth_chainId -> -32601 method not found): the
|
|
66
|
+
Aster Chain EVM-RPC is closed.
|
|
67
|
+
|
|
68
|
+
## Solana vault (https://api.mainnet-beta.solana.com)
|
|
69
|
+
|
|
70
|
+
- `getSignaturesForAddress` for the Aster vault program
|
|
71
|
+
`EhUtRgu9iEbZXXRpEvDj6n1wnQRjMi2SERDo3c6bmN2c` - live 200 keyless,
|
|
72
|
+
fresh signatures (signature / slot / blockTime / err). Treated
|
|
73
|
+
politely (10 req/min local cap) - it is a free public node.
|
|
74
|
+
|
|
75
|
+
## EVM vaults (BSC / ETH / ARB)
|
|
76
|
+
|
|
77
|
+
- Vaults: BSC `0x128463A60784c4D3f46c23Af3f65Ed859Ba87974`, ETH
|
|
78
|
+
`0x604DD02d620633Ae427888d41bfd15e38483736E`, ARB
|
|
79
|
+
`0x9E36CB86a159d479cEd94Fa05036f235Ac40E1d5`.
|
|
80
|
+
- Deposit flow = ERC-20 `Transfer` (topic0 `0xddf252ad...523b3ef`)
|
|
81
|
+
with `to` = vault, via `eth_getLogs`.
|
|
82
|
+
- Free public RPCs reject even 300-block windows with **-32005
|
|
83
|
+
(limit exceeded)**, so the RPC URL comes from env:
|
|
84
|
+
`ASTER_EVM_RPC_URL` (generic) or `ASTER_EVM_RPC_URL_{BSC,ETH,ARB}`
|
|
85
|
+
(per-chain). Unset -> honest `"no RPC configured"` error dict -
|
|
86
|
+
never a traceback, never invented logs.
|
|
87
|
+
|
|
88
|
+
## Endpoints NOT used, and why
|
|
89
|
+
|
|
90
|
+
- **`/futures/data/*` history endpoints** - 404 on Aster (only
|
|
91
|
+
openInterestHist was probed; the futures/data tree is absent).
|
|
92
|
+
- **WebSocket streams** (`fstream`/`sstream`) - deferred; the MCP
|
|
93
|
+
request/response model gains little from push feeds and the TTL
|
|
94
|
+
caches already cover freshness.
|
|
95
|
+
- **Signed/trading endpoints** - out of scope by design: this gateway
|
|
96
|
+
is strictly read-only and keyless; no API keys ever enter the
|
|
97
|
+
process.
|
|
98
|
+
- **aster-scan.com / asterscan.io explorers** - dead/unreachable at
|
|
99
|
+
recon time; not a dependency.
|
aster_mcp/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""aster_mcp - read-only, keyless MCP gateway to Aster DEX public data.
|
|
2
|
+
|
|
3
|
+
Package layout:
|
|
4
|
+
rest.py stdlib-only REST client for fapi.asterdex.com/fapi/v3
|
|
5
|
+
and sapi.asterdex.com/api/v3 (weight-aware, TTL caches)
|
|
6
|
+
chain.py stdlib-only clients for tapi (Aster Chain JSON-RPC),
|
|
7
|
+
Solana vault signatures and EVM vault Transfer logs
|
|
8
|
+
server.py FastMCP server wiring the 13 read-only tools
|
|
9
|
+
|
|
10
|
+
No API keys, no auth headers, no signing, no order placement - public
|
|
11
|
+
data only.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
aster_mcp/chain.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Stdlib-only clients for Aster on-chain surfaces: tapi (Aster Chain
|
|
2
|
+
JSON-RPC), Solana vault signatures, and EVM vault Transfer logs.
|
|
3
|
+
|
|
4
|
+
tapi POST https://tapi.asterdex.com/info {"jsonrpc":"2.0","id":1,
|
|
5
|
+
"method":"aster_getBalance","params":{...}} - answers keyless,
|
|
6
|
+
but account privacy hides most accounts: private accounts return
|
|
7
|
+
an EMPTY result (or accountPrivacy:"enabled") - that is an honest
|
|
8
|
+
degradation, never an error-dict pretending data exists. Methods
|
|
9
|
+
used (read-only): aster_getBalance / aster_openOrders /
|
|
10
|
+
aster_userFills (+ spot variants when they exist).
|
|
11
|
+
|
|
12
|
+
solana RPC https://api.mainnet-beta.solana.com getSignaturesForAddress
|
|
13
|
+
for the Aster vault program
|
|
14
|
+
EhUtRgu9iEbZXXRpEvDj6n1wnQRjMi2SERDo3c6bmN2c (keyless, fresh
|
|
15
|
+
signatures: signature/slot/blockTime/err).
|
|
16
|
+
|
|
17
|
+
evm eth_getLogs Transfer(topic0=0xddf252ad...) to the Aster vault
|
|
18
|
+
contracts on BSC/ETH/ARB. Free public RPCs reject even 300-block
|
|
19
|
+
windows with -32005 (limit exceeded), so the RPC URL comes from
|
|
20
|
+
env ASTER_EVM_RPC_URL / ASTER_EVM_RPC_URL_{BSC,ETH,ARB}; when
|
|
21
|
+
unset the tools return an honest "no RPC configured" error dict -
|
|
22
|
+
NEVER an invented log, NEVER a traceback.
|
|
23
|
+
|
|
24
|
+
Each surface keeps its own small rate ledger (solana 10 req/min is
|
|
25
|
+
polite for a free public node; tapi 30/min) and raises RpcError with a
|
|
26
|
+
taxonomy kind so tools can degrade honestly.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import itertools
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import threading
|
|
34
|
+
import time
|
|
35
|
+
import urllib.error
|
|
36
|
+
import urllib.request
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
TAPI_URL = "https://tapi.asterdex.com/info"
|
|
40
|
+
SOLANA_RPC = "https://api.mainnet-beta.solana.com"
|
|
41
|
+
ASTER_SOLANA_VAULT = "EhUtRgu9iEbZXXRpEvDj6n1wnQRjMi2SERDo3c6bmN2c"
|
|
42
|
+
|
|
43
|
+
# EVM vaults (official Aster docs; chain -> vault address)
|
|
44
|
+
EVM_VAULTS = {
|
|
45
|
+
"bsc": "0x128463a60784c4d3f46c23af3f65ed859ba87974",
|
|
46
|
+
"eth": "0x604dd02d620633ae427888d41bfd15e38483736e",
|
|
47
|
+
"arb": "0x9e36cb86a159d479ced94fa05036f235ac40e1d5",
|
|
48
|
+
}
|
|
49
|
+
TRANSFER_TOPIC = ("0xddf252ad1be2c89b69c2b068fc378daa952ba7f1"
|
|
50
|
+
"63c4a11628f55a4df523b3ef")
|
|
51
|
+
|
|
52
|
+
UA = {"User-Agent": "aster-agent-gateway/0.1",
|
|
53
|
+
"Content-Type": "application/json"}
|
|
54
|
+
|
|
55
|
+
_TIMEOUT = 15.0
|
|
56
|
+
_RETRIES = 2 # these are secondary surfaces: fail fast, degrade honestly
|
|
57
|
+
|
|
58
|
+
_IDS = itertools.count(1)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RpcError(Exception):
|
|
62
|
+
"""On-chain RPC failure with a taxonomy kind.
|
|
63
|
+
|
|
64
|
+
kind: "rpc-limit" (local request budget / server 429), "rpc-http"
|
|
65
|
+
(JSON-RPC error payload, e.g. -32005 range limit), "rpc-timeout",
|
|
66
|
+
"rpc-network", "no-rpc" (env not configured - EVM only).
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, message: str, *, kind: str,
|
|
70
|
+
status: int | None = None, code: int | None = None):
|
|
71
|
+
super().__init__(message)
|
|
72
|
+
self.kind = kind
|
|
73
|
+
self.status = status
|
|
74
|
+
self.code = code
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Shared rolling-window request budgets (one per surface)
|
|
79
|
+
|
|
80
|
+
_BUDGETS: dict[str, dict] = {
|
|
81
|
+
"tapi": {"limit": 30, "ts": []},
|
|
82
|
+
"solana": {"limit": 10, "ts": []},
|
|
83
|
+
"evm": {"limit": 20, "ts": []},
|
|
84
|
+
}
|
|
85
|
+
_BUDGET_LOCK = threading.Lock()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def requests_last_minute(surface: str) -> int:
|
|
89
|
+
"""Requests admitted on a surface in the rolling 60s (test hook)."""
|
|
90
|
+
with _BUDGET_LOCK:
|
|
91
|
+
_prune(surface, time.monotonic())
|
|
92
|
+
return len(_BUDGETS[surface]["ts"])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _prune(surface: str, now: float) -> None:
|
|
96
|
+
cutoff = now - 60.0
|
|
97
|
+
lst = _BUDGETS[surface]["ts"]
|
|
98
|
+
while lst and lst[0] <= cutoff:
|
|
99
|
+
lst.pop(0)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _admit(surface: str) -> None:
|
|
103
|
+
with _BUDGET_LOCK:
|
|
104
|
+
now = time.monotonic()
|
|
105
|
+
_prune(surface, now)
|
|
106
|
+
b = _BUDGETS[surface]
|
|
107
|
+
if len(b["ts"]) >= b["limit"]:
|
|
108
|
+
raise RpcError(
|
|
109
|
+
f"{surface} local request budget exhausted "
|
|
110
|
+
f"({b['limit']}/min); wait for the window to roll",
|
|
111
|
+
kind="rpc-limit")
|
|
112
|
+
b["ts"].append(now)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Generic JSON-RPC POST (tapi / solana / evm share the shape)
|
|
117
|
+
|
|
118
|
+
def rpc_post(url: str, method: str, params: list | dict | None = None,
|
|
119
|
+
surface: str = "tapi", retries: int = _RETRIES) -> Any:
|
|
120
|
+
"""POST one JSON-RPC request; returns the `result` field.
|
|
121
|
+
|
|
122
|
+
Honors JSON-RPC error payloads (code/message) and retries 429/5xx
|
|
123
|
+
twice. Raises RpcError on final failure."""
|
|
124
|
+
body = json.dumps({"jsonrpc": "2.0", "id": next(_IDS),
|
|
125
|
+
"method": method, "params": params or {}}).encode()
|
|
126
|
+
for attempt in range(retries):
|
|
127
|
+
_admit(surface)
|
|
128
|
+
req = urllib.request.Request(url, data=body, headers=UA)
|
|
129
|
+
try:
|
|
130
|
+
with urllib.request.urlopen(req, timeout=_TIMEOUT) as r:
|
|
131
|
+
payload = json.loads(r.read())
|
|
132
|
+
except urllib.error.HTTPError as e:
|
|
133
|
+
if (e.code in (429, 418) or 500 <= e.code <= 599) \
|
|
134
|
+
and attempt < retries - 1:
|
|
135
|
+
ra = e.headers.get("Retry-After")
|
|
136
|
+
try:
|
|
137
|
+
delay = min(30.0, float(ra)) if ra else 2.0 * (attempt + 1)
|
|
138
|
+
except ValueError:
|
|
139
|
+
delay = 2.0 * (attempt + 1)
|
|
140
|
+
time.sleep(delay)
|
|
141
|
+
continue
|
|
142
|
+
try:
|
|
143
|
+
detail = e.read(300).decode("utf-8", "replace")
|
|
144
|
+
except Exception:
|
|
145
|
+
detail = ""
|
|
146
|
+
raise RpcError(
|
|
147
|
+
f"{surface} rpc {e.code} on {method}: "
|
|
148
|
+
f"{detail or e.reason}",
|
|
149
|
+
kind="rpc-limit" if e.code in (429, 418) else "rpc-http",
|
|
150
|
+
status=e.code) from e
|
|
151
|
+
except TimeoutError as e:
|
|
152
|
+
raise RpcError(f"{surface} rpc timeout on {method} after "
|
|
153
|
+
f"{_TIMEOUT}s", kind="rpc-timeout") from e
|
|
154
|
+
except (urllib.error.URLError, ConnectionError, OSError) as e:
|
|
155
|
+
if isinstance(e, urllib.error.URLError) and isinstance(
|
|
156
|
+
getattr(e, "reason", None), TimeoutError):
|
|
157
|
+
raise RpcError(f"{surface} rpc timeout on {method} after "
|
|
158
|
+
f"{_TIMEOUT}s", kind="rpc-timeout") from e
|
|
159
|
+
if attempt < retries - 1:
|
|
160
|
+
time.sleep(2.0 * (attempt + 1))
|
|
161
|
+
continue
|
|
162
|
+
raise RpcError(f"{surface} rpc unreachable on {method}: {e}",
|
|
163
|
+
kind="rpc-network") from e
|
|
164
|
+
except ValueError as e:
|
|
165
|
+
raise RpcError(f"{surface} rpc invalid JSON on {method}: {e}",
|
|
166
|
+
kind="rpc-http") from e
|
|
167
|
+
if "error" in payload:
|
|
168
|
+
err = payload.get("error") or {}
|
|
169
|
+
raise RpcError(
|
|
170
|
+
f"{surface} rpc error on {method}: "
|
|
171
|
+
f"{err.get('code')} {err.get('message')}",
|
|
172
|
+
kind="rpc-http", code=err.get("code")) from None
|
|
173
|
+
if "result" not in payload:
|
|
174
|
+
raise RpcError(f"{surface} rpc malformed response on "
|
|
175
|
+
f"{method}: no result", kind="rpc-http")
|
|
176
|
+
return payload["result"]
|
|
177
|
+
raise RpcError(f"{surface} rpc failed on {method}", kind="rpc-network")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# tapi: Aster Chain account views (read-only, keyless, privacy-aware)
|
|
182
|
+
|
|
183
|
+
TAPI_METHODS = {
|
|
184
|
+
"balance": "aster_getBalance",
|
|
185
|
+
"openOrders": "aster_openOrders",
|
|
186
|
+
"userFills": "aster_userFills",
|
|
187
|
+
"spotBalance": "aster_spotGetBalance",
|
|
188
|
+
"spotOpenOrders": "aster_spotOpenOrders",
|
|
189
|
+
"spotUserFills": "aster_spotUserFills",
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def tapi_call(method: str, address: str, extra: dict | None = None) -> Any:
|
|
194
|
+
"""One tapi read for `address`. Params use the DOCUMENTED array form
|
|
195
|
+
[address, blockTag] (live-verified: the object {"userAddress": ...}
|
|
196
|
+
form returns HTTP 400). extra params are ignored for now - tapi
|
|
197
|
+
methods take only the two positional params. Returns the raw
|
|
198
|
+
result; the SERVER layer decides privacy-empty vs data (see
|
|
199
|
+
server.account_view)."""
|
|
200
|
+
params: list = [(address or "").strip(), "latest"]
|
|
201
|
+
return rpc_post(TAPI_URL, method, params, surface="tapi")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def tapi_balance(address: str) -> Any:
|
|
205
|
+
return tapi_call("aster_getBalance", address)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def tapi_open_orders(address: str) -> Any:
|
|
209
|
+
return tapi_call("aster_openOrders", address)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def tapi_user_fills(address: str) -> Any:
|
|
213
|
+
return tapi_call("aster_userFills", address)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Solana vault signatures
|
|
218
|
+
|
|
219
|
+
def solana_vault_signatures(limit: int = 20,
|
|
220
|
+
vault: str = ASTER_SOLANA_VAULT) -> list:
|
|
221
|
+
"""getSignaturesForAddress on the Aster Solana vault program.
|
|
222
|
+
Rows: signature/slot/blockTime/err (newest first)."""
|
|
223
|
+
n = max(1, min(100, int(limit)))
|
|
224
|
+
return rpc_post(SOLANA_RPC, "getSignaturesForAddress",
|
|
225
|
+
[vault, {"limit": n}], surface="solana") or []
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# EVM vault Transfer logs (env-gated, honest when unconfigured)
|
|
230
|
+
|
|
231
|
+
def evm_rpc_url(chain: str) -> str | None:
|
|
232
|
+
"""ASTER_EVM_RPC_URL_{CHAIN} wins over the generic
|
|
233
|
+
ASTER_EVM_RPC_URL; None when nothing is configured."""
|
|
234
|
+
chain = (chain or "").lower()
|
|
235
|
+
specific = os.environ.get(f"ASTER_EVM_RPC_URL_{chain.upper()}")
|
|
236
|
+
if specific:
|
|
237
|
+
return specific
|
|
238
|
+
return os.environ.get("ASTER_EVM_RPC_URL")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def evm_vault_transfers(chain: str, from_block: int, to_block: int,
|
|
242
|
+
limit_blocks: int = 300) -> list:
|
|
243
|
+
"""eth_getLogs Transfer->vault on `chain`. The window is clamped to
|
|
244
|
+
`limit_blocks` (free RPCs reject wide ranges with -32005). Raises
|
|
245
|
+
RpcError(kind="no-rpc") when no RPC is configured."""
|
|
246
|
+
chain = (chain or "").lower()
|
|
247
|
+
if chain not in EVM_VAULTS:
|
|
248
|
+
raise ValueError(f"unknown chain {chain!r}: use one of "
|
|
249
|
+
f"{', '.join(sorted(EVM_VAULTS))}")
|
|
250
|
+
url = evm_rpc_url(chain)
|
|
251
|
+
if not url:
|
|
252
|
+
raise RpcError(
|
|
253
|
+
f"no RPC configured for {chain}: set ASTER_EVM_RPC_URL or "
|
|
254
|
+
f"ASTER_EVM_RPC_URL_{chain.upper()} (free public RPCs reject "
|
|
255
|
+
f"vault log queries with -32005, so a real endpoint is "
|
|
256
|
+
f"required)", kind="no-rpc")
|
|
257
|
+
vault = EVM_VAULTS[chain]
|
|
258
|
+
lo = max(0, min(from_block, to_block - limit_blocks))
|
|
259
|
+
hi = to_block
|
|
260
|
+
logs = rpc_post(url, "eth_getLogs", [{
|
|
261
|
+
"fromBlock": hex(lo), "toBlock": hex(hi),
|
|
262
|
+
"address": vault,
|
|
263
|
+
"topics": [TRANSFER_TOPIC, None,
|
|
264
|
+
"0x" + "0" * 24 + vault[2:]], # Transfer(_, vault)
|
|
265
|
+
}], surface="evm")
|
|
266
|
+
return logs or []
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def evm_latest_block(chain: str) -> int:
|
|
270
|
+
"""eth_blockNumber on the configured RPC (int)."""
|
|
271
|
+
url = evm_rpc_url(chain)
|
|
272
|
+
if not url:
|
|
273
|
+
raise RpcError(f"no RPC configured for {chain}",
|
|
274
|
+
kind="no-rpc")
|
|
275
|
+
res = rpc_post(url, "eth_blockNumber", [], surface="evm")
|
|
276
|
+
try:
|
|
277
|
+
return int(res, 16)
|
|
278
|
+
except (TypeError, ValueError):
|
|
279
|
+
raise RpcError(f"eth_blockNumber returned non-hex: {res!r}",
|
|
280
|
+
kind="rpc-http")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def reset_budgets() -> None:
|
|
284
|
+
"""Clear the per-surface request ledgers (test hook)."""
|
|
285
|
+
with _BUDGET_LOCK:
|
|
286
|
+
for b in _BUDGETS.values():
|
|
287
|
+
b["ts"].clear()
|