coinex-mcp-server 0.1.0a1__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.
- coinex_mcp_server/__init__.py +13 -0
- coinex_mcp_server/coinex_client.py +393 -0
- coinex_mcp_server/main.py +637 -0
- coinex_mcp_server-0.1.0a1.dist-info/METADATA +237 -0
- coinex_mcp_server-0.1.0a1.dist-info/RECORD +8 -0
- coinex_mcp_server-0.1.0a1.dist-info/WHEEL +5 -0
- coinex_mcp_server-0.1.0a1.dist-info/licenses/LICENSE +201 -0
- coinex_mcp_server-0.1.0a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
MCP server for accessing CoinEx cryptocurrency exchange
|
|
4
|
+
|
|
5
|
+
Notes:
|
|
6
|
+
- In HTTP/SSE mode, only tags={"public"} query tools are exposed by default; enable --enable-http-auth to expose tags={"auth"} tools.
|
|
7
|
+
- When accessing account/trading tools (tags={"auth"}), provide in request headers:
|
|
8
|
+
- X-CoinEx-Access-Id: Your API Key
|
|
9
|
+
- X-CoinEx-Secret-Key: Your API Secret
|
|
10
|
+
- All tools return following CoinEx style: { code: int, message: str, data: any }
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Any, Annotated, Literal
|
|
16
|
+
from pydantic import Field, validate_call
|
|
17
|
+
|
|
18
|
+
from fastmcp import FastMCP
|
|
19
|
+
from fastmcp.server.auth import StaticTokenVerifier
|
|
20
|
+
from fastmcp.server.dependencies import get_http_headers
|
|
21
|
+
from .coinex_client import CoinExClient, validate_environment
|
|
22
|
+
import os
|
|
23
|
+
import argparse
|
|
24
|
+
|
|
25
|
+
# Load .env (won't override externally set environment variables)
|
|
26
|
+
try:
|
|
27
|
+
from dotenv import load_dotenv, find_dotenv
|
|
28
|
+
# When started via MCP Inspector, CWD is usually set to project root; find_dotenv is more robust
|
|
29
|
+
env_path = find_dotenv(usecwd=True)
|
|
30
|
+
if env_path:
|
|
31
|
+
load_dotenv(env_path, override=False)
|
|
32
|
+
print(f"Loaded environment variables from {env_path}", file=sys.stderr)
|
|
33
|
+
else:
|
|
34
|
+
print(".env not found, continuing with system environment variables", file=sys.stderr)
|
|
35
|
+
except Exception as e:
|
|
36
|
+
# Give hint when python-dotenv is not installed or other exceptions occur, but don't block service
|
|
37
|
+
print(f"Failed to load .env: {e} (you can run pip install python-dotenv)", file=sys.stderr)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Enum field descriptions (for reuse across tool functions)
|
|
41
|
+
MARKET_TYPE_DESC = "Market type: spot|futures|margin; default spot"
|
|
42
|
+
ORDER_SIDE_DESC = "Order side: buy|sell"
|
|
43
|
+
ORDER_STATUS_DESC = "Order status: pending|finished"
|
|
44
|
+
|
|
45
|
+
# Initialize FastMCP server
|
|
46
|
+
mcp = FastMCP("coinex-mcp-server")
|
|
47
|
+
|
|
48
|
+
# Delayed initialization: decide whether to allow reading credentials from environment based on transport and auth mode
|
|
49
|
+
coinex_client: CoinExClient | None = None
|
|
50
|
+
is_http_like: bool = False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_secret_client() -> CoinExClient:
|
|
54
|
+
"""Get authenticated client based on current transport mode.
|
|
55
|
+
|
|
56
|
+
- HTTP/SSE mode: Extract credentials from request headers
|
|
57
|
+
- stdio mode: Use global coinex_client with environment credentials
|
|
58
|
+
|
|
59
|
+
Returns: CoinExClient configured with appropriate credentials.
|
|
60
|
+
"""
|
|
61
|
+
if is_http_like:
|
|
62
|
+
# HTTP/SSE mode - extract credentials from request headers
|
|
63
|
+
headers = get_http_headers() # lowercase dictionary
|
|
64
|
+
access_id = headers.get("x-coinex-access-id")
|
|
65
|
+
secret_key = headers.get("x-coinex-secret-key")
|
|
66
|
+
|
|
67
|
+
if not access_id or not secret_key:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
"Request headers must include X-CoinEx-Access-Id and X-CoinEx-Secret-Key to access account/trading interfaces"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return CoinExClient(access_id=access_id, secret_key=secret_key, enable_env_credentials=False)
|
|
73
|
+
else:
|
|
74
|
+
# stdio mode - use global client with environment credentials
|
|
75
|
+
if coinex_client is None:
|
|
76
|
+
raise ValueError("CoinEx client not initialized")
|
|
77
|
+
|
|
78
|
+
# Verify global client has credentials
|
|
79
|
+
if not hasattr(coinex_client, 'access_id') or not coinex_client.access_id:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
"CoinEx API credentials not found. Please set COINEX_ACCESS_ID and COINEX_SECRET_KEY environment variables"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return coinex_client
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# =====================
|
|
88
|
+
# Public Market Queries (spot/futures)
|
|
89
|
+
# =====================
|
|
90
|
+
@mcp.tool(tags={"public"})
|
|
91
|
+
@validate_call
|
|
92
|
+
async def get_ticker(
|
|
93
|
+
base: Annotated[str | None, Field(description="Base currency, e.g. BTC, ETH; returns top 5 when empty")] = None,
|
|
94
|
+
quote: Annotated[str, Field(description="Quote currency, default USDT")] = "USDT",
|
|
95
|
+
market_type: Annotated[CoinExClient.MarketType, Field(description=MARKET_TYPE_DESC)] = CoinExClient.MarketType.SPOT,
|
|
96
|
+
) -> dict[str, Any]:
|
|
97
|
+
"""Get trading pair's recent price, 24h price and volume information (spot).
|
|
98
|
+
|
|
99
|
+
Parameters:
|
|
100
|
+
- base: Optional, base currency like "BTC", "ETH". When not provided, returns top 5 entries.
|
|
101
|
+
- quote: Optional, quote currency, default "USDT".
|
|
102
|
+
|
|
103
|
+
Returns: {code, message, data}; when base is not provided, only returns top 5 items.
|
|
104
|
+
"""
|
|
105
|
+
api_result = await coinex_client.get_tickers(base, quote, market_type)
|
|
106
|
+
|
|
107
|
+
if api_result.get('code') != 0 or 'data' not in api_result:
|
|
108
|
+
logging.error(f"get_ticker error, code:{api_result.get('code')}, message:{api_result.get('message')}")
|
|
109
|
+
return api_result
|
|
110
|
+
|
|
111
|
+
data = api_result['data']
|
|
112
|
+
if not base and isinstance(data, list):
|
|
113
|
+
api_result['data'] = data[:5]
|
|
114
|
+
return api_result
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@mcp.tool(tags={"public"})
|
|
118
|
+
@validate_call
|
|
119
|
+
async def get_orderbook(
|
|
120
|
+
base: Annotated[str, Field(description="Required, base currency, e.g. BTC, ETH")],
|
|
121
|
+
quote: Annotated[str, Field(description="Quote currency, default USDT")] = "USDT",
|
|
122
|
+
limit: Annotated[int | None, Field(description="Number of price levels to return, options: 5/10/20/50; default 20")] = 20,
|
|
123
|
+
market_type: Annotated[CoinExClient.MarketType, Field(description=MARKET_TYPE_DESC)] = CoinExClient.MarketType.SPOT,
|
|
124
|
+
interval: Annotated[str | None, Field(description="Merge granularity, default 0; values according to official documentation")] = "0",
|
|
125
|
+
) -> dict[str, Any]:
|
|
126
|
+
"""Get order book (depth) information (supports spot/futures).
|
|
127
|
+
|
|
128
|
+
Parameters:
|
|
129
|
+
- base: Required, base currency. Example: "BTC", "ETH".
|
|
130
|
+
- quote: Optional, quote currency, default "USDT".
|
|
131
|
+
- limit: Optional, number of price levels to return, default 20; valid values: [5, 10, 20, 50].
|
|
132
|
+
- market_type: Optional, market type, default "spot"; valid values: "spot" | "futures".
|
|
133
|
+
- interval: Optional, merge granularity, default "0"; valid values include "0", "0.00000001", ..., "1", "10", "100", "1000" (according to official documentation).
|
|
134
|
+
|
|
135
|
+
Returns: {code, message, data}.
|
|
136
|
+
"""
|
|
137
|
+
api_result = await coinex_client.get_depth(base, quote, market_type, limit or 20, interval or "0")
|
|
138
|
+
|
|
139
|
+
if api_result.get('code') != 0 or 'data' not in api_result:
|
|
140
|
+
logging.error(f"get_depth error, code:{api_result.get('code')}, message:{api_result.get('message')}")
|
|
141
|
+
return api_result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@mcp.tool(tags={"public"})
|
|
145
|
+
@validate_call
|
|
146
|
+
async def get_kline(
|
|
147
|
+
base: Annotated[str, Field(description="Required, base currency, e.g. BTC, ETH")],
|
|
148
|
+
quote: Annotated[str, Field(description="Quote currency, default USDT")] = "USDT",
|
|
149
|
+
period: Annotated[
|
|
150
|
+
Literal["1min", "5min", "15min", "30min", "1hour", "4hour", "1day", "1week"],
|
|
151
|
+
Field(description="K-line period; default 1hour; supports 1min/5min/15min/30min/1hour/4hour/1day/1week")
|
|
152
|
+
] = "1hour",
|
|
153
|
+
limit: Annotated[int | None, Field(description="Number of records to return; default 100")] = 100,
|
|
154
|
+
market_type: Annotated[CoinExClient.MarketType, Field(description=MARKET_TYPE_DESC)] = CoinExClient.MarketType.SPOT,
|
|
155
|
+
) -> dict[str, Any]:
|
|
156
|
+
"""Get K-line data (supports spot/futures).
|
|
157
|
+
|
|
158
|
+
Parameters:
|
|
159
|
+
- base: Required, base currency. Example: "BTC", "ETH".
|
|
160
|
+
- quote: Optional, quote currency, default "USDT".
|
|
161
|
+
- period: Optional, K-line period, default "1hour";
|
|
162
|
+
- Spot/futures common period whitelist: "1min","5min","15min","30min","1hour","4hour","1day","1week".
|
|
163
|
+
- limit: Optional, number of records, default 100.
|
|
164
|
+
- market_type: Optional, market type, default "spot"; options: "spot" | "futures".
|
|
165
|
+
|
|
166
|
+
Error: When period is not in whitelist, returns {code:-1, message:"Unsupported time period"}.
|
|
167
|
+
Returns: {code, message, data}.
|
|
168
|
+
"""
|
|
169
|
+
valid_periods = ["1min", "5min", "15min", "30min", "1hour", "4hour", "1day", "1week"]
|
|
170
|
+
|
|
171
|
+
if period not in valid_periods:
|
|
172
|
+
return {"code": -1, "message": f"Unsupported time period: {period}."}
|
|
173
|
+
|
|
174
|
+
api_result = await coinex_client.get_kline(str(period), base, quote, market_type, limit)
|
|
175
|
+
|
|
176
|
+
if api_result.get('code') != 0 or 'data' not in api_result:
|
|
177
|
+
logging.error(f"get_kline error, code:{api_result.get('code')}, message:{api_result.get('message')}")
|
|
178
|
+
return api_result
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ===============
|
|
182
|
+
# Additional Public Tools
|
|
183
|
+
# ===============
|
|
184
|
+
|
|
185
|
+
@mcp.tool(tags={"public"})
|
|
186
|
+
@validate_call
|
|
187
|
+
async def list_markets(
|
|
188
|
+
market_type: Annotated[CoinExClient.MarketType, Field(description=MARKET_TYPE_DESC)] = CoinExClient.MarketType.SPOT,
|
|
189
|
+
base: Annotated[str | None, Field(description="Optional; base currency to filter")] = None,
|
|
190
|
+
quote: Annotated[str, Field(description="Quote currency, default USDT")] = "USDT",
|
|
191
|
+
) -> dict[str, Any]:
|
|
192
|
+
"""List market status (spot/futures).
|
|
193
|
+
|
|
194
|
+
Parameters:
|
|
195
|
+
- market_type: Optional, default "spot"; options: "spot" | "futures".
|
|
196
|
+
- base: Optional, base currency to filter.
|
|
197
|
+
- quote: Optional, quote currency, default "USDT".
|
|
198
|
+
|
|
199
|
+
Returns: {code, message, data} (list).
|
|
200
|
+
"""
|
|
201
|
+
api_result = await coinex_client.get_market_info(base, quote, market_type)
|
|
202
|
+
if api_result.get('code') != 0 or 'data' not in api_result:
|
|
203
|
+
logging.error(f"list_markets error, code:{api_result.get('code')}, message:{api_result.get('message')}")
|
|
204
|
+
return api_result
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@mcp.tool(tags={"public"})
|
|
208
|
+
@validate_call
|
|
209
|
+
async def get_deals(
|
|
210
|
+
base: Annotated[str, Field(description="Required, base currency, e.g. BTC, ETH")],
|
|
211
|
+
quote: Annotated[str, Field(description="Quote currency, default USDT")] = "USDT",
|
|
212
|
+
market_type: Annotated[CoinExClient.MarketType, Field(description=MARKET_TYPE_DESC)] = CoinExClient.MarketType.SPOT,
|
|
213
|
+
limit: Annotated[int | None, Field(description="Return quantity, default 100, max 1000 (per official docs)")] = 100,
|
|
214
|
+
) -> dict[str, Any]:
|
|
215
|
+
"""Get recent trades (deals).
|
|
216
|
+
|
|
217
|
+
Parameters:
|
|
218
|
+
- base: Required, base currency.
|
|
219
|
+
- quote: Optional, quote currency, default "USDT".
|
|
220
|
+
- market_type: Optional, default "spot"; options: "spot" | "futures".
|
|
221
|
+
- limit: Optional, return quantity, default 100, max 1000 (per official documentation).
|
|
222
|
+
|
|
223
|
+
Returns: {code, message, data} (list).
|
|
224
|
+
"""
|
|
225
|
+
api_result = await coinex_client.get_deal(base, quote, market_type, limit)
|
|
226
|
+
|
|
227
|
+
if api_result.get('code') != 0 or 'data' not in api_result:
|
|
228
|
+
logging.error(f"get_deals error, code:{api_result.get('code')}, message:{api_result.get('message')}")
|
|
229
|
+
return api_result
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@mcp.tool(tags={"public"})
|
|
233
|
+
@validate_call
|
|
234
|
+
async def get_index_price(
|
|
235
|
+
market_type: Annotated[CoinExClient.MarketType, Field(description=MARKET_TYPE_DESC)] = CoinExClient.MarketType.SPOT,
|
|
236
|
+
base: Annotated[str | None, Field(description="Optional; base currency, returns multi-market index if not provided")] = None,
|
|
237
|
+
quote: Annotated[str, Field(description="Quote currency, default USDT")] = "USDT",
|
|
238
|
+
top_n: Annotated[int | None, Field(description="Return top N entries when base not provided; default 5")] = 5,
|
|
239
|
+
) -> dict[str, Any]:
|
|
240
|
+
"""Get market index price (spot/futures). Supports batch; returns top N entries when base not provided.
|
|
241
|
+
|
|
242
|
+
Parameters:
|
|
243
|
+
- market_type: Optional, default "spot"; options: "spot" | "futures".
|
|
244
|
+
- base: Optional, base currency; returns multi-market index when not provided.
|
|
245
|
+
- quote: Optional, quote currency, default "USDT".
|
|
246
|
+
- top_n: Optional, only effective when base not provided; default 5.
|
|
247
|
+
|
|
248
|
+
Returns: {code, message, data}.
|
|
249
|
+
"""
|
|
250
|
+
api_result = await coinex_client.get_index_price(base, quote, market_type)
|
|
251
|
+
|
|
252
|
+
if api_result.get('code') != 0 or 'data' not in api_result:
|
|
253
|
+
logging.error(f"get_index_price error, code:{api_result.get('code')}, message:{api_result.get('message')}")
|
|
254
|
+
return api_result
|
|
255
|
+
|
|
256
|
+
if not base and isinstance(api_result.get('data'), list) and top_n:
|
|
257
|
+
api_result['data'] = api_result['data'][:top_n]
|
|
258
|
+
return api_result
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ====== Futures-Specific ======
|
|
262
|
+
|
|
263
|
+
@mcp.tool(tags={"public"})
|
|
264
|
+
async def get_funding_rate(
|
|
265
|
+
base: Annotated[str, Field(description="Required, futures base currency, e.g. BTC, ETH")],
|
|
266
|
+
quote: Annotated[str, Field(description="Quote currency, default USDT")] = "USDT"
|
|
267
|
+
) -> dict[str, Any]:
|
|
268
|
+
"""Get current funding rate (futures only).
|
|
269
|
+
|
|
270
|
+
Parameters:
|
|
271
|
+
- base: Required, futures base currency, e.g. "BTC", "ETH".
|
|
272
|
+
- quote: Optional, quote currency, default "USDT".
|
|
273
|
+
|
|
274
|
+
Returns: {code, message, data}.
|
|
275
|
+
"""
|
|
276
|
+
api_result = await coinex_client.futures_get_funding_rate(base, quote)
|
|
277
|
+
if api_result.get('code') != 0 or 'data' not in api_result:
|
|
278
|
+
logging.error(f"get_funding_rate error, code:{api_result.get('code')}, message:{api_result.get('message')}")
|
|
279
|
+
return api_result
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@mcp.tool(tags={"public"})
|
|
283
|
+
async def get_funding_rate_history(
|
|
284
|
+
base: Annotated[str, Field(description="Required, futures base currency, e.g. BTC, ETH")],
|
|
285
|
+
quote: Annotated[str, Field(description="Quote currency, default USDT")] = "USDT",
|
|
286
|
+
start_time: Annotated[int | None, Field(description="Start timestamp (milliseconds)")] = None,
|
|
287
|
+
end_time: Annotated[int | None, Field(description="End timestamp (milliseconds)")] = None,
|
|
288
|
+
page: Annotated[int | None, Field(description="Page number; default 1")] = 1,
|
|
289
|
+
limit: Annotated[int | None, Field(description="Number of records; default 100")] = 100,
|
|
290
|
+
) -> dict[str, Any]:
|
|
291
|
+
"""Get funding rate history (futures only).
|
|
292
|
+
|
|
293
|
+
Parameters:
|
|
294
|
+
- base: Required, futures base currency.
|
|
295
|
+
- quote: Optional, quote currency, default "USDT".
|
|
296
|
+
- start_time: Optional, start timestamp (milliseconds).
|
|
297
|
+
- end_time: Optional, end timestamp (milliseconds).
|
|
298
|
+
- page: Optional, default 1.
|
|
299
|
+
- limit: Optional, default 100.
|
|
300
|
+
|
|
301
|
+
Returns: {code, message, data}.
|
|
302
|
+
"""
|
|
303
|
+
api_result = await coinex_client.futures_get_funding_rate_history(base, quote, start_time, end_time, page, limit)
|
|
304
|
+
if api_result.get('code') != 0 or 'data' not in api_result:
|
|
305
|
+
logging.error(f"get_funding_rate_history error, code:{api_result.get('code')}, message:{api_result.get('message')}")
|
|
306
|
+
return api_result
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@mcp.tool(tags={"public"})
|
|
310
|
+
async def get_premium_index_history(
|
|
311
|
+
base: Annotated[str, Field(description="Required, futures base currency")],
|
|
312
|
+
quote: Annotated[str, Field(description="Quote currency, default USDT")] = "USDT",
|
|
313
|
+
start_time: Annotated[int | None, Field(description="Start timestamp (milliseconds)")] = None,
|
|
314
|
+
end_time: Annotated[int | None, Field(description="End timestamp (milliseconds)")] = None,
|
|
315
|
+
page: Annotated[int | None, Field(description="Page number; default 1")] = 1,
|
|
316
|
+
limit: Annotated[int | None, Field(description="Number of records; default 100")] = 100,
|
|
317
|
+
) -> dict[str, Any]:
|
|
318
|
+
"""Get premium index history (futures only).
|
|
319
|
+
|
|
320
|
+
Parameters:
|
|
321
|
+
- base: Required, futures base currency.
|
|
322
|
+
- quote: Optional, quote currency, default "USDT".
|
|
323
|
+
- start_time: Optional, start timestamp (milliseconds).
|
|
324
|
+
- end_time: Optional, end timestamp (milliseconds).
|
|
325
|
+
- page: Optional, default 1.
|
|
326
|
+
- limit: Optional, default 100.
|
|
327
|
+
|
|
328
|
+
Returns: {code, message, data}.
|
|
329
|
+
"""
|
|
330
|
+
api_result = await coinex_client.futures_get_premium_history(base, quote, start_time, end_time, page, limit)
|
|
331
|
+
if api_result.get('code') != 0 or 'data' not in api_result:
|
|
332
|
+
logging.error(f"get_premium_index_history error, code:{api_result.get('code')}, message:{api_result.get('message')}")
|
|
333
|
+
return api_result
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@mcp.tool(tags={"public"})
|
|
337
|
+
async def get_basis_history(
|
|
338
|
+
base: Annotated[str, Field(description="Required, futures base currency")],
|
|
339
|
+
quote: Annotated[str, Field(description="Quote currency, default USDT")] = "USDT",
|
|
340
|
+
start_time: Annotated[int | None, Field(description="Start timestamp (milliseconds)")] = None,
|
|
341
|
+
end_time: Annotated[int | None, Field(description="End timestamp (milliseconds)")] = None,
|
|
342
|
+
page: Annotated[int | None, Field(description="Placeholder parameter (currently unused)")] = 1,
|
|
343
|
+
limit: Annotated[int | None, Field(description="Placeholder parameter (currently unused)")] = 100,
|
|
344
|
+
) -> dict[str, Any]:
|
|
345
|
+
"""Get basis history (futures only).
|
|
346
|
+
|
|
347
|
+
Parameters:
|
|
348
|
+
- base: Required, futures base currency.
|
|
349
|
+
- quote: Optional, quote currency, default "USDT".
|
|
350
|
+
- start_time: Optional, start timestamp (milliseconds).
|
|
351
|
+
- end_time: Optional, end timestamp (milliseconds).
|
|
352
|
+
- page: Optional, placeholder parameter (currently unused by client).
|
|
353
|
+
- limit: Optional, placeholder parameter (currently unused by client).
|
|
354
|
+
|
|
355
|
+
Returns: {code, message, data}.
|
|
356
|
+
"""
|
|
357
|
+
api_result = await coinex_client.futures_basis_index_history(base, quote, start_time, end_time, page, limit)
|
|
358
|
+
if api_result.get('code') != 0 or 'data' not in api_result:
|
|
359
|
+
logging.error(f"get_basis_history error, code:{api_result.get('code')}, message:{api_result.get('message')}")
|
|
360
|
+
return api_result
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@mcp.tool(tags={"public"})
|
|
364
|
+
async def get_margin_tiers(
|
|
365
|
+
base: Annotated[str, Field(description="Required, futures base currency")],
|
|
366
|
+
quote: Annotated[str, Field(description="Quote currency, default USDT")] = "USDT"
|
|
367
|
+
) -> dict[str, Any]:
|
|
368
|
+
"""Get margin tiers/ position levels (futures only).
|
|
369
|
+
|
|
370
|
+
Parameters:
|
|
371
|
+
- base: Required, futures base currency.
|
|
372
|
+
- quote: Optional, quote currency, default "USDT".
|
|
373
|
+
|
|
374
|
+
Returns: {code, message, data}.
|
|
375
|
+
"""
|
|
376
|
+
api_result = await coinex_client.futures_get_position_level(base, quote)
|
|
377
|
+
if api_result.get('code') != 0 or 'data' not in api_result:
|
|
378
|
+
logging.error(f"get_position_tiers error, code:{api_result.get('code')}, message:{api_result.get('message')}")
|
|
379
|
+
return api_result
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@mcp.tool(tags={"public"})
|
|
383
|
+
async def get_liquidation_history(
|
|
384
|
+
base: Annotated[str, Field(description="Required, futures base currency, e.g. BTC, ETH")],
|
|
385
|
+
quote: Annotated[str, Field(description="Quote currency, default USDT")] = "USDT",
|
|
386
|
+
start_time: Annotated[int | None, Field(description="Start timestamp (milliseconds)")] = None,
|
|
387
|
+
end_time: Annotated[int | None, Field(description="End timestamp (milliseconds)")] = None,
|
|
388
|
+
page: Annotated[int | None, Field(description="Page number; default 1")] = 1,
|
|
389
|
+
limit: Annotated[int | None, Field(description="Number of records; default 100")] = 100,
|
|
390
|
+
) -> dict[str, Any]:
|
|
391
|
+
"""Get liquidation history (futures only).
|
|
392
|
+
|
|
393
|
+
Parameters:
|
|
394
|
+
- base: Required, futures base currency.
|
|
395
|
+
- quote: Optional, quote currency, default "USDT".
|
|
396
|
+
- start_time: Optional, start timestamp (milliseconds).
|
|
397
|
+
- end_time: Optional, end timestamp (milliseconds).
|
|
398
|
+
- page: Optional, page number, default 1.
|
|
399
|
+
- limit: Optional, number of records, default 100.
|
|
400
|
+
|
|
401
|
+
Returns: {code, message, data}.
|
|
402
|
+
"""
|
|
403
|
+
# Note: This function doesn't exist in the new API, return a placeholder response
|
|
404
|
+
return {"code": -1, "message": "Liquidation history not available in current API version", "data": []}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
@mcp.tool(tags={"auth"})
|
|
408
|
+
async def get_account_balance() -> dict[str, Any]:
|
|
409
|
+
"""Get account balance information (requires authentication).
|
|
410
|
+
|
|
411
|
+
Usage (HTTP/SSE): Include in request headers:
|
|
412
|
+
- X-CoinEx-Access-Id
|
|
413
|
+
- X-CoinEx-Secret-Key
|
|
414
|
+
|
|
415
|
+
Returns: {code, message, data}.
|
|
416
|
+
"""
|
|
417
|
+
client = get_secret_client()
|
|
418
|
+
api_result = await client.get_balances(CoinExClient.MarketType.SPOT)
|
|
419
|
+
|
|
420
|
+
if api_result.get('code') != 0 or 'data' not in api_result:
|
|
421
|
+
logging.error(f"get_balances error, code:{api_result.get('code')}, message:{api_result.get('message')}")
|
|
422
|
+
return api_result
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@mcp.tool(tags={"auth"})
|
|
426
|
+
@validate_call
|
|
427
|
+
async def place_order(
|
|
428
|
+
base: Annotated[str, Field(description="Required, base currency, e.g. BTC, ETH")],
|
|
429
|
+
side: Annotated[CoinExClient.OrderSide, Field(description=ORDER_SIDE_DESC)],
|
|
430
|
+
amount: Annotated[str, Field(description="Required, order quantity (string), must meet precision and minimum volume requirements")],
|
|
431
|
+
quote: Annotated[str, Field(description="Quote currency, default USDT")] = "USDT",
|
|
432
|
+
price: Annotated[str | None, Field(description="Optional; if provided, creates a limit order, otherwise market order")] = None,
|
|
433
|
+
is_hide: Annotated[bool | None, Field(description="Optional, whether to place a hidden order")] = None,
|
|
434
|
+
client_id: Annotated[str | None, Field(description="Optional, custom order ID")] = None,
|
|
435
|
+
trigger_price: Annotated[str | None, Field(description="Optional, if provided, creates a stop order based on it")] = None,
|
|
436
|
+
market_type: Annotated[CoinExClient.MarketType, Field(description=MARKET_TYPE_DESC)] = CoinExClient.MarketType.SPOT,
|
|
437
|
+
) -> dict[str, Any]:
|
|
438
|
+
"""Place trading order (requires authentication, real funds).
|
|
439
|
+
Strong reminder: This is a real money trading operation. Please confirm with end user again before calling!
|
|
440
|
+
|
|
441
|
+
Parameters:
|
|
442
|
+
- base: Required, base currency.
|
|
443
|
+
- side: Required, direction: buy|sell.
|
|
444
|
+
- amount: Required, order quantity (string).
|
|
445
|
+
- quote: Optional, quote currency, default "USDT".
|
|
446
|
+
- price: Optional; if provided, creates limit order, otherwise market order.
|
|
447
|
+
- is_hide: Optional, whether to place a hidden order.
|
|
448
|
+
- client_id: Optional, custom order ID.
|
|
449
|
+
- trigger_price: Optional, if provided, creates a stop order.
|
|
450
|
+
- market_type: Optional, market type, default "spot".
|
|
451
|
+
|
|
452
|
+
Returns: {code, message, data}. Logs error on failure.
|
|
453
|
+
"""
|
|
454
|
+
client = get_secret_client()
|
|
455
|
+
|
|
456
|
+
api_result = await client.place_order(
|
|
457
|
+
side=side,
|
|
458
|
+
base=base,
|
|
459
|
+
quote=quote,
|
|
460
|
+
amount=amount,
|
|
461
|
+
market_type=market_type,
|
|
462
|
+
price=price,
|
|
463
|
+
is_hide=is_hide,
|
|
464
|
+
client_id=client_id,
|
|
465
|
+
trigger_price=trigger_price
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
if api_result.get('code') != 0 or 'data' not in api_result:
|
|
469
|
+
logging.error(f"place_order error, code:{api_result.get('code')}, message:{api_result.get('message')}")
|
|
470
|
+
return api_result
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
@mcp.tool(tags={"auth"})
|
|
474
|
+
@validate_call
|
|
475
|
+
async def cancel_order(
|
|
476
|
+
base: Annotated[str, Field(description="Required, base currency, e.g. BTC, ETH")],
|
|
477
|
+
quote: Annotated[str, Field(description="Quote currency, default USDT")] = "USDT",
|
|
478
|
+
order_id: Annotated[int, Field(description="Optional, order ID to cancel, if empty, cancels all orders")] = None,
|
|
479
|
+
market_type: Annotated[CoinExClient.MarketType, Field(description=MARKET_TYPE_DESC)] = CoinExClient.MarketType.SPOT,
|
|
480
|
+
) -> dict[str, Any]:
|
|
481
|
+
"""Cancel order (requires authentication).
|
|
482
|
+
|
|
483
|
+
Parameters:
|
|
484
|
+
- base: Required, base currency.
|
|
485
|
+
- order_id: Optional, order ID to cancel, if empty, cancels all orders.
|
|
486
|
+
- quote: Optional, quote currency, default "USDT".
|
|
487
|
+
- market_type: Optional, market type, default "spot".
|
|
488
|
+
|
|
489
|
+
Returns: {code, message, data}.
|
|
490
|
+
"""
|
|
491
|
+
client = get_secret_client()
|
|
492
|
+
api_result = await client.cancel_order(base, quote, market_type, order_id)
|
|
493
|
+
|
|
494
|
+
if api_result.get('code') != 0 or 'data' not in api_result:
|
|
495
|
+
logging.error(f"cancel_order error, code:{api_result.get('code')}, message:{api_result.get('message')}")
|
|
496
|
+
return api_result
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
@mcp.tool(tags={"auth"})
|
|
500
|
+
@validate_call
|
|
501
|
+
async def get_order_history(
|
|
502
|
+
base: Annotated[str | None, Field(description="Optional, base currency; query all markets if empty")] = None,
|
|
503
|
+
quote: Annotated[str, Field(description="Quote currency, default USDT")] = "USDT",
|
|
504
|
+
side: Annotated[CoinExClient.OrderSide | None, Field(description=ORDER_SIDE_DESC)] = None,
|
|
505
|
+
status: Annotated[CoinExClient.OrderStatus, Field(description=ORDER_STATUS_DESC)] = CoinExClient.OrderStatus.FINISHED,
|
|
506
|
+
is_stop: Annotated[bool | None, Field(description="Optional, whether to query stop orders; default False")] = False,
|
|
507
|
+
page: Annotated[int | None, Field(description="Optional, page number; default 1")] = 1,
|
|
508
|
+
limit: Annotated[int | None, Field(description="Optional, total return limit; default 100")] = 100,
|
|
509
|
+
market_type: Annotated[CoinExClient.MarketType, Field(description=MARKET_TYPE_DESC)] = CoinExClient.MarketType.SPOT,
|
|
510
|
+
) -> dict[str, Any]:
|
|
511
|
+
"""Get order history (requires authentication).
|
|
512
|
+
|
|
513
|
+
Description: Returns list of orders; limit applies to total merged count.
|
|
514
|
+
|
|
515
|
+
Parameters:
|
|
516
|
+
- base: Optional, base currency; queries all markets if empty.
|
|
517
|
+
- quote: Optional, quote currency, default "USDT".
|
|
518
|
+
- side: Optional, order side.
|
|
519
|
+
- status: Optional, order status, pending(open) or finished, default 'finished'.
|
|
520
|
+
- is_stop: Optional, whether to query stop orders, default False.
|
|
521
|
+
- page: Optional, page number, default 1.
|
|
522
|
+
- limit: Optional, default 100.
|
|
523
|
+
- market_type: Optional, market type, default "spot".
|
|
524
|
+
|
|
525
|
+
Returns: {code, message, data}, sorted by time descending (determined by server/API).
|
|
526
|
+
"""
|
|
527
|
+
client = get_secret_client()
|
|
528
|
+
|
|
529
|
+
api_result = await client.get_orders(
|
|
530
|
+
base, quote, market_type, side, status, is_stop, page, limit
|
|
531
|
+
)
|
|
532
|
+
return api_result
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
if __name__ == "__main__":
|
|
536
|
+
parser = argparse.ArgumentParser(description="CoinEx FastMCP server startup parameters")
|
|
537
|
+
parser.add_argument(
|
|
538
|
+
"--transport",
|
|
539
|
+
choices=["stdio", "http", "streamable-http", "sse"],
|
|
540
|
+
default="stdio",
|
|
541
|
+
help="Transport protocol: stdio(default) | http(equivalent to streamable-http) | streamable-http | sse",
|
|
542
|
+
)
|
|
543
|
+
parser.add_argument("--host", default=None, help="HTTP service bind address (only valid in http/streamable-http mode)")
|
|
544
|
+
parser.add_argument("--port", type=int, default=None, help="HTTP service port (only valid in http/streamable-http mode)")
|
|
545
|
+
parser.add_argument(
|
|
546
|
+
"--path",
|
|
547
|
+
default=None,
|
|
548
|
+
help="Endpoint path: /mcp path in http/streamable-http mode; mount path in sse mode",
|
|
549
|
+
)
|
|
550
|
+
parser.add_argument(
|
|
551
|
+
"--enable-http-auth",
|
|
552
|
+
action="store_true",
|
|
553
|
+
help="Enable HTTP-based authentication and sensitive tools (default off, only exposes query tools)",
|
|
554
|
+
)
|
|
555
|
+
# Added: Worker processes and port reuse (only valid in HTTP/SSE mode)
|
|
556
|
+
parser.add_argument(
|
|
557
|
+
"--workers",
|
|
558
|
+
type=int,
|
|
559
|
+
default=None,
|
|
560
|
+
help="Number of worker processes in HTTP/SSE mode (managed by underlying uvicorn)",
|
|
561
|
+
)
|
|
562
|
+
parser.add_argument(
|
|
563
|
+
"--reuse-port",
|
|
564
|
+
action="store_true",
|
|
565
|
+
help="Enable SO_REUSEPORT for multi-process (use with caution, only when multiple independent processes need to share same port)",
|
|
566
|
+
)
|
|
567
|
+
args = parser.parse_args()
|
|
568
|
+
|
|
569
|
+
# Compatible with common "http" notation in documentation
|
|
570
|
+
transport = args.transport
|
|
571
|
+
if transport == "http":
|
|
572
|
+
transport = "streamable-http"
|
|
573
|
+
|
|
574
|
+
# Calculate HTTP auth switch: default off; only enable when explicitly turned on via command line or environment variable
|
|
575
|
+
env_http_auth_enabled = os.getenv("HTTP_AUTH_ENABLED", "false").lower() in ("1", "true", "yes", "on")
|
|
576
|
+
http_auth_enabled = args.enable_http_auth or env_http_auth_enabled
|
|
577
|
+
|
|
578
|
+
# Apply switch under HTTP/SSE transport
|
|
579
|
+
is_http_like = transport in ("streamable-http", "sse")
|
|
580
|
+
|
|
581
|
+
# Initialize client for public data access based on mode (will not carry credentials)
|
|
582
|
+
if is_http_like:
|
|
583
|
+
# Disable environment credential fallback in any HTTP/SSE mode
|
|
584
|
+
coinex_client = CoinExClient(enable_env_credentials=False)
|
|
585
|
+
print("HTTP/SSE mode: Environment credential fallback disabled.", file=sys.stderr)
|
|
586
|
+
else:
|
|
587
|
+
# Only non-HTTP mode allows loading from environment (common scenario for local stdio development/self-hosting)
|
|
588
|
+
coinex_client = CoinExClient(enable_env_credentials=True)
|
|
589
|
+
has_credentials = validate_environment()
|
|
590
|
+
if not has_credentials:
|
|
591
|
+
print("Error: CoinEx API credentials not found, some features will be unavailable", file=sys.stderr)
|
|
592
|
+
|
|
593
|
+
if is_http_like:
|
|
594
|
+
if not http_auth_enabled:
|
|
595
|
+
# Only expose public tag tools
|
|
596
|
+
mcp.include_tags = {"public"}
|
|
597
|
+
print("HTTP authentication disabled: Only exposing query tools (public)", file=sys.stderr)
|
|
598
|
+
else:
|
|
599
|
+
# Optional: Enable Bearer authentication based on environment variables (static Token)
|
|
600
|
+
API_TOKEN = os.getenv("API_TOKEN")
|
|
601
|
+
if API_TOKEN:
|
|
602
|
+
scopes_env = os.getenv("API_SCOPES", "").replace(",", " ").split()
|
|
603
|
+
mcp.auth = StaticTokenVerifier(
|
|
604
|
+
tokens={
|
|
605
|
+
API_TOKEN: {
|
|
606
|
+
"client_id": "api-token",
|
|
607
|
+
"scopes": scopes_env,
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
required_scopes=scopes_env or None,
|
|
611
|
+
)
|
|
612
|
+
print("Bearer authentication enabled (API_TOKEN)", file=sys.stderr)
|
|
613
|
+
|
|
614
|
+
# Assemble uvicorn configuration (only effective in HTTP/SSE mode)
|
|
615
|
+
uvicorn_config = None
|
|
616
|
+
if is_http_like:
|
|
617
|
+
uvicorn_config = {}
|
|
618
|
+
if args.workers is not None:
|
|
619
|
+
uvicorn_config["workers"] = args.workers
|
|
620
|
+
if args.reuse_port:
|
|
621
|
+
uvicorn_config["reuse_port"] = True
|
|
622
|
+
# If finally empty, change to None to avoid passing empty configuration
|
|
623
|
+
if not uvicorn_config:
|
|
624
|
+
uvicorn_config = None
|
|
625
|
+
|
|
626
|
+
# Start (2.x: only pass HTTP params for HTTP transports)
|
|
627
|
+
if transport == "stdio":
|
|
628
|
+
mcp.run(transport=transport)
|
|
629
|
+
else:
|
|
630
|
+
mcp.run(
|
|
631
|
+
transport=transport,
|
|
632
|
+
host=args.host,
|
|
633
|
+
port=args.port,
|
|
634
|
+
path=args.path,
|
|
635
|
+
# Only pass when configured, avoid affecting stdio
|
|
636
|
+
**({"uvicorn_config": uvicorn_config} if uvicorn_config is not None else {})
|
|
637
|
+
)
|