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.
@@ -0,0 +1,13 @@
1
+ """CoinEx MCP Server - A Model Context Protocol server for CoinEx cryptocurrency exchange.
2
+
3
+ This package enables AI agents to interact with the CoinEx exchange API through
4
+ the Model Context Protocol (MCP), supporting both spot and futures markets.
5
+ """
6
+
7
+ __version__ = "0.1.0a1"
8
+ __author__ = "CoinEx MCP Contributors"
9
+ __license__ = "Apache-2.0"
10
+
11
+ from .coinex_client import CoinExClient
12
+
13
+ __all__ = ["CoinExClient", "__version__"]
@@ -0,0 +1,393 @@
1
+ """
2
+ CoinEx API Client
3
+ Implements authentication, signing and request functionality for CoinEx API v2
4
+ """
5
+
6
+ import os
7
+ import time
8
+ import hmac
9
+ import hashlib
10
+ import json
11
+ from enum import Enum
12
+ from typing import Any, Dict, Optional, List
13
+ from urllib.parse import urlencode
14
+ import httpx
15
+
16
+
17
+ class CoinExClient:
18
+ """CoinEx API Client"""
19
+ class MarketType(Enum):
20
+ SPOT = "spot"
21
+ MARGIN = "margin"
22
+ FUTURES = "futures"
23
+
24
+ class OrderType(Enum):
25
+ LIMIT = "limit"
26
+ MARKET = "market"
27
+
28
+ class OrderSide(Enum):
29
+ BUY = "buy"
30
+ SELL = "sell"
31
+
32
+ class OrderStatus(Enum):
33
+ PENDING = "pending"
34
+ FINISHED = "finished"
35
+
36
+ def __init__(self, access_id: str = None, secret_key: str = None, *, enable_env_credentials: bool = True):
37
+ """Initialize CoinEx client
38
+ :param access_id: API access ID
39
+ :param secret_key: API secret key
40
+ :param enable_env_credentials: Whether to allow fallback reading from environment variables COINEX_ACCESS_ID/COINEX_SECRET_KEY
41
+ """
42
+
43
+ if enable_env_credentials:
44
+ self.access_id = access_id or os.getenv('COINEX_ACCESS_ID')
45
+ self.secret_key = secret_key or os.getenv('COINEX_SECRET_KEY')
46
+ else:
47
+ # Strictly use passed parameters, do not take over from environment
48
+ self.access_id = access_id
49
+ self.secret_key = secret_key
50
+ self.base_url = "https://api.coinex.com" # CoinEx doesn't have a dedicated testnet, use mainnet
51
+ self.timeout = 30
52
+
53
+ def _generate_signature(self, method: str, path: str, params: Dict = None, body: str = "") -> tuple[str, str]:
54
+ """Generate API signature"""
55
+ if not self.secret_key:
56
+ raise ValueError("secret_key is required to generate signature")
57
+
58
+ timestamp = str(int(time.time() * 1000))
59
+
60
+ # Build string to be signed
61
+ if params:
62
+ query_string = urlencode(params)
63
+ prepared_str = f"{method}{path}?{query_string}{body}{timestamp}"
64
+ else:
65
+ prepared_str = f"{method}{path}{body}{timestamp}"
66
+
67
+ # Generate signature using HMAC-SHA256
68
+ signature = hmac.new(
69
+ self.secret_key.encode('utf-8'),
70
+ prepared_str.encode('utf-8'),
71
+ hashlib.sha256
72
+ ).hexdigest()
73
+
74
+ return signature, timestamp
75
+
76
+ def _get_headers(self, method: str, path: str, params: Dict = None, body: str = "") -> Dict[str, str]:
77
+ """Get request headers"""
78
+ headers = {
79
+ "Content-Type": "application/json",
80
+ "User-Agent": "coinex-mcp-server/1.0"
81
+ }
82
+
83
+ # If authentication information is available, add signature headers
84
+ if self.access_id and self.secret_key:
85
+ signature, timestamp = self._generate_signature(method, path, params, body)
86
+ headers.update({
87
+ "X-COINEX-KEY": self.access_id,
88
+ "X-COINEX-SIGN": signature,
89
+ "X-COINEX-TIMESTAMP": timestamp
90
+ })
91
+
92
+ return headers
93
+
94
+ # --------------------
95
+ # Generic public market query helpers
96
+ # --------------------
97
+ @classmethod
98
+ def _market_type_str_in_path(cls, market_type: MarketType) -> str:
99
+ return market_type.value if market_type != cls.MarketType.MARGIN else cls.MarketType.SPOT.value
100
+
101
+ @classmethod
102
+ def _build_market_path(cls, market_type: MarketType, endpoint: str) -> str:
103
+ """Assemble generic path, e.g. endpoint='market' -> '/v2/spot/market'."""
104
+ mt = cls._market_type_str_in_path(market_type)
105
+ endpoint = endpoint.lstrip('/')
106
+ return f"/v2/{mt}/{endpoint}"
107
+
108
+ # Unified: public market queries (spot/futures routing)
109
+ async def _market_request(self, endpoint: str, method: str = 'GET', base_currency: Optional[str] = None,
110
+ quote_currency: Optional[str] = None, market_type: MarketType = MarketType.SPOT,
111
+ extra_params: Optional[Dict[str, Any]] = None):
112
+ """Unified market request with market type routing and symbol normalization.
113
+ - market_type: 'spot' | 'futures'
114
+ - endpoint: e.g. 'market', 'ticker', 'order', 'cancel-order', 'deals', 'depth', 'kline'
115
+ - method: HTTP method 'GET' | 'POST' | 'DELETE', default 'GET'
116
+ - markets: str or [str], market symbol(s), maybe needs to be normalized
117
+ - extra_params: additional query parameters, such as limit/interval/period
118
+ """
119
+ path = self._build_market_path(market_type, endpoint)
120
+
121
+ data: Dict[str, Any] = {}
122
+
123
+ if base_currency and not quote_currency or quote_currency and not base_currency:
124
+ raise ValueError(f"Base currency is '{base_currency}' and quote_currency is {quote_currency}, this is meaningless!")
125
+
126
+ if base_currency and quote_currency:
127
+ market = base_currency + quote_currency
128
+ data['market'] = market
129
+
130
+ if extra_params:
131
+ data.update(extra_params)
132
+
133
+ return await self._request(method, path, data=data)
134
+
135
+ async def _request(self, method: str, path: str, data: Dict = None) -> Dict[str, Any]:
136
+ """Send HTTP request
137
+
138
+ :param method: HTTP method (GET/POST/DELETE)
139
+ :param path: API path
140
+ :param data: Request data - for GET requests becomes URL params, for POST/DELETE becomes request body
141
+ """
142
+ url = f"{self.base_url}{path}"
143
+
144
+ # Determine params and body based on HTTP method
145
+ params = None
146
+ request_body = ""
147
+
148
+ if method.upper() == "GET":
149
+ # GET requests use URL parameters
150
+ params = data
151
+ else:
152
+ # POST/DELETE requests use request body
153
+ if data:
154
+ request_body = json.dumps(data, separators=(',', ':'))
155
+
156
+ # Get request headers
157
+ headers = self._get_headers(method, path, params, request_body)
158
+
159
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
160
+ """Send HTTP request"""
161
+ try:
162
+ if method.upper() == "GET":
163
+ response = await client.get(url, params=params, headers=headers)
164
+ elif method.upper() == "POST":
165
+ response = await client.post(url, headers=headers, content=request_body)
166
+ else:
167
+ # coinex api doesn't have DELETE/PUT requests
168
+ raise ValueError(f"Unsupported HTTP method: {method}")
169
+
170
+ response.raise_for_status()
171
+ return response.json()
172
+
173
+ except httpx.TimeoutException:
174
+ raise Exception("Request timeout")
175
+ except httpx.HTTPStatusError as e:
176
+ error_msg = f"HTTP error {e.response.status_code}"
177
+ try:
178
+ error_data = e.response.json()
179
+ if 'message' in error_data:
180
+ error_msg += f": {error_data['message']}"
181
+ except (ValueError, KeyError, AttributeError):
182
+ pass
183
+ raise Exception(error_msg)
184
+ except Exception as e:
185
+ raise Exception(f"Request failed: {str(e)}")
186
+
187
+ # =====================
188
+ # Unified public market queries
189
+ # =====================
190
+ async def get_market_info(self, base: str | None = None, quote: str | None = None, market_type: MarketType | None = None) -> Dict[str, Any]:
191
+ return await self._market_request('market', base_currency=base, quote_currency=quote, market_type=market_type)
192
+
193
+ async def get_tickers(self, base: str | None = None, quote: str | None = None, market_type: MarketType | None = None) -> Dict[str, Any]:
194
+ return await self._market_request('ticker', base_currency=base, quote_currency=quote, market_type=market_type)
195
+
196
+ async def get_depth(self, base: str, quote: str = 'USDT', market_type: MarketType | None = None, limit: int = 20, interval: str = "0") -> Dict[str, Any]:
197
+ extra = {"limit": limit, "interval": interval}
198
+ return await self._market_request('depth', base_currency=base, quote_currency=quote, market_type=market_type,
199
+ extra_params=extra)
200
+
201
+ async def get_kline(self, period: str, base: str, quote: str = 'USDT', market_type: MarketType | None = None, limit: int = 100) -> Dict[str, Any]:
202
+ extra = {"period": period, "limit": limit}
203
+ return await self._market_request('kline', base_currency=base, quote_currency=quote, market_type=market_type,
204
+ extra_params=extra)
205
+
206
+ async def get_deal(self, base: str, quote: str = 'USDT', market_type: MarketType | None = None, limit: int = 100) -> Dict[str, Any]:
207
+ return await self._market_request('deals', base_currency=base, quote_currency=quote, market_type=market_type,
208
+ extra_params={"limit": limit})
209
+
210
+ async def get_index_price(self, base: str, quote: str = 'USDT', market_type: MarketType | None = None) -> Dict[str, Any]:
211
+ return await self._market_request('index', base_currency=base, quote_currency=quote, market_type=market_type)
212
+
213
+ # =====================
214
+ # Futures public market queries
215
+ # =====================
216
+ async def futures_get_funding_rate(self, base: str, quote: str = 'USDT'):
217
+ """Get current funding rate (futures)"""
218
+ return await self._market_request("funding-rate", base_currency=base, quote_currency=quote, market_type=self.MarketType.FUTURES)
219
+
220
+ async def futures_get_funding_rate_history(self, base: str, quote: str = 'USDT',
221
+ start_time: Optional[int] = None,
222
+ end_time: Optional[int] = None, page: int = 1, limit: int = 10) -> Dict[str, Any]:
223
+ """Get funding rate history (futures)"""
224
+ extra_params: Dict[str, Any] = {"page": page, "limit": limit}
225
+ if start_time is not None:
226
+ extra_params["start_time"] = start_time
227
+ if end_time is not None:
228
+ extra_params["end_time"] = end_time
229
+ return await self._market_request("funding-rate-history",
230
+ base_currency=base, quote_currency=quote, market_type=self.MarketType.FUTURES,
231
+ extra_params=extra_params)
232
+
233
+ async def futures_get_premium_history(self, base: str, quote: str = 'USDT',
234
+ start_time: Optional[int] = None, end_time: Optional[int] = None,
235
+ page: int = 1, limit: int = 10) -> Dict[str, Any]:
236
+ """Get premium index history (futures)"""
237
+ extra_params: Dict[str, Any] = {"page": page, "limit": limit}
238
+ if start_time is not None:
239
+ extra_params["start_time"] = start_time
240
+ if end_time is not None:
241
+ extra_params["end_time"] = end_time
242
+ return await self._market_request("premium-index-history",
243
+ base_currency=base, quote_currency=quote, market_type=self.MarketType.FUTURES,
244
+ extra_params=extra_params)
245
+
246
+ async def futures_get_position_level(self, base: str, quote: str = 'USDT') -> Dict[str, Any]:
247
+ """Get position levels (futures)"""
248
+ return await self._market_request("position-level", base_currency=base, quote_currency=quote,
249
+ market_type=self.MarketType.FUTURES)
250
+
251
+ async def futures_premium_index_history(self, base: str, quote: str = 'USDT',
252
+ start_time: Optional[int] = None, end_time: Optional[int] = None,
253
+ page: int = 1, limit: int = 10) -> Dict[str, Any]:
254
+ """Get premium index history (futures)"""
255
+ extra_params: Dict[str, Any] = {"page": page, "limit": limit}
256
+ if start_time is not None:
257
+ extra_params["start_time"] = start_time
258
+ if end_time is not None:
259
+ extra_params["end_time"] = end_time
260
+ return await self._market_request("premium-index-history",
261
+ base_currency=base, quote_currency=quote, market_type=self.MarketType.FUTURES,
262
+ extra_params=extra_params)
263
+
264
+ async def futures_basis_index_history(self, base: str, quote: str = 'USDT',
265
+ start_time: Optional[int] = None, end_time: Optional[int] = None,
266
+ page: int = 1, limit: int = 10) -> Dict[str, Any]:
267
+ extra_params: Dict[str, Any] = {"page": page, "limit": limit}
268
+ if start_time is not None:
269
+ extra_params["start_time"] = start_time
270
+ if end_time is not None:
271
+ extra_params["end_time"] = end_time
272
+ return await self._market_request("basis-history",
273
+ base_currency=base, quote_currency=quote, market_type=self.MarketType.FUTURES,
274
+ extra_params=extra_params)
275
+
276
+ # =====================
277
+ # personal account info( require authentication)
278
+ # =====================
279
+ # Account interfaces
280
+ async def get_balances(self, market_type: MarketType = MarketType.SPOT) -> Dict[str, Any]:
281
+ """Get account balance"""
282
+ if not self.access_id or not self.secret_key:
283
+ raise ValueError("Account interface requires access_id and secret_key")
284
+
285
+ path = f"/v2/assets/{self._market_type_str_in_path(market_type)}/balance"
286
+ return await self._request("GET", path, data=None)
287
+
288
+ # Trading interfaces (authentication required)
289
+ async def place_order(self, side: OrderSide, base: str, quote: str, amount: str,
290
+ market_type: MarketType = MarketType.SPOT,
291
+ price: str = None,
292
+ is_hide: bool = None, client_id: str = None,
293
+ trigger_price: str = None, stp_mode: str = None) -> Dict[str, Any]:
294
+ """Place order.
295
+ This is an important interface that involves fund operations, so both the base and quote parameters must be explicitly specified by the user.
296
+ Parameters:
297
+ side: buy/sell
298
+ base: base currency
299
+ quote: quote currency
300
+ amount: order quantity (string), must meet precision and minimum volume requirements
301
+ market_type: spot/futures/margin
302
+ price: optional, if provided, creates a limit order; otherwise, creates a market order
303
+ is_hide: optional, if True, places a hidden order
304
+ trigger_price: optional, if provided, creates a stop order
305
+ stp_mode: optional, self-trade prevention mode
306
+ client_id: optional, custom order ID
307
+ """
308
+ if not self.access_id or not self.secret_key:
309
+ raise ValueError("Trading interface requires access_id and secret_key")
310
+
311
+ params = {
312
+ "market_type": market_type.name,
313
+ "side": side.value,
314
+ "amount": amount,
315
+ }
316
+
317
+ if price:
318
+ params["price"] = price
319
+ params["type"] = self.OrderType.LIMIT.value
320
+ else:
321
+ params["type"] = self.OrderType.MARKET.value
322
+
323
+ if client_id:
324
+ params["client_id"] = client_id
325
+ if is_hide:
326
+ params["is_hide"] = 'true'
327
+
328
+ if trigger_price:
329
+ params["trigger_price"] = trigger_price
330
+ if stp_mode:
331
+ params["stp_mode"] = stp_mode
332
+
333
+ endpoint = 'stop-order' if trigger_price else "order"
334
+
335
+ return await self._market_request(endpoint, 'POST', base, quote,
336
+ market_type=market_type, extra_params=params)
337
+
338
+ # Trading interfaces (authentication required)
339
+ async def cancel_order(self, base: str, quote: str, market_type: MarketType = MarketType.SPOT,
340
+ order_id: int | None = None) -> Dict[str, Any]:
341
+ """Cancel an order or all orders in a market.
342
+ This is an important interface that involves fund operations, so both the base and quote parameters must be explicitly specified by the user.
343
+ Parameters:
344
+ base: base currency
345
+ quote: quote currency
346
+ market_type: spot/futures/margin
347
+ order_id: optional, if provided, cancels the specific order; otherwise, cancels all orders in the market
348
+ """
349
+ if not self.access_id or not self.secret_key:
350
+ raise ValueError("Trading interface requires access_id and secret_key")
351
+ endpoint = "cancel-order" if order_id else "cancel-all-orders"
352
+ params: Dict[str, Any] = {"market_type": market_type.name}
353
+ if order_id:
354
+ params["order_id"] = order_id
355
+ return await self._market_request(endpoint, 'POST', base, quote,
356
+ market_type=market_type, extra_params=params)
357
+
358
+ # Trading interfaces (authentication required)
359
+ async def get_orders(self, base: str = None, quote: str = None,
360
+ market_type: MarketType = MarketType.SPOT,
361
+ side: OrderSide = None,
362
+ status: OrderStatus = OrderStatus.FINISHED,
363
+ is_stop=False,
364
+ page: int = 1, limit: int = 100) -> Dict[str, Any]:
365
+ if not self.access_id or not self.secret_key:
366
+ raise ValueError("Account interface requires access_id and secret_key")
367
+
368
+ extra_params = {
369
+ "market_type": market_type.value,
370
+ "page": page,
371
+ "limit": limit
372
+ }
373
+ if side:
374
+ extra_params["side"] = side.value
375
+
376
+ stop_seg = "stop-" if is_stop else ""
377
+ endpoint = f"{status.value}-{stop_seg}order"
378
+ return await self._market_request(endpoint, 'GET', base, quote,
379
+ market_type=market_type, extra_params=extra_params)
380
+
381
+
382
+ def validate_environment():
383
+ """Validate environment variable configuration"""
384
+ access_id = os.getenv('COINEX_ACCESS_ID')
385
+ secret_key = os.getenv('COINEX_SECRET_KEY')
386
+
387
+ if not access_id or not secret_key:
388
+ print("Warning: COINEX_ACCESS_ID and COINEX_SECRET_KEY environment variables not set")
389
+ print("Some features (account info, trading) will be unavailable")
390
+ print("Market data features can still be used normally")
391
+ return False
392
+
393
+ return True