diff --git a/handlers/agents/_shared.py b/handlers/agents/_shared.py index 43e43743..311928f4 100644 --- a/handlers/agents/_shared.py +++ b/handlers/agents/_shared.py @@ -509,6 +509,7 @@ def build_initial_context(user_id: int, chat_id: int, user_data: dict | None = N "mcp__mcp-hummingbot__explore_dex_pools", "mcp__mcp-hummingbot__explore_geckoterminal", "mcp__mcp-hummingbot__manage_gateway_swaps", + "mcp__mcp-hummingbot__manage_gateway_clmm", "mcp__mcp-hummingbot__manage_gateway_config", "mcp__mcp-hummingbot__manage_gateway_container", "mcp__mcp-hummingbot__search_history", diff --git a/mcp_servers/hummingbot_api/formatters/__init__.py b/mcp_servers/hummingbot_api/formatters/__init__.py index 529a7edf..9d06f633 100644 --- a/mcp_servers/hummingbot_api/formatters/__init__.py +++ b/mcp_servers/hummingbot_api/formatters/__init__.py @@ -39,6 +39,7 @@ # Gateway formatters from .gateway import ( + format_gateway_clmm_result, format_gateway_clmm_pool_result, format_gateway_config_result, format_gateway_container_result, @@ -76,6 +77,7 @@ "format_gateway_container_result", "format_gateway_config_result", "format_gateway_swap_result", + "format_gateway_clmm_result", "format_gateway_clmm_pool_result", # Base utilities "format_currency", diff --git a/mcp_servers/hummingbot_api/formatters/gateway.py b/mcp_servers/hummingbot_api/formatters/gateway.py index e4ebfdac..e78ed09f 100644 --- a/mcp_servers/hummingbot_api/formatters/gateway.py +++ b/mcp_servers/hummingbot_api/formatters/gateway.py @@ -128,6 +128,61 @@ def format_gateway_swap_result(action: str, result: dict[str, Any]) -> str: return f"Gateway Swap Result: {result}" +def _extract_transaction_hash(result: dict[str, Any]) -> str | None: + return ( + result.get("transaction_hash") + or result.get("tx_hash") + or result.get("txHash") + or result.get("signature") + or result.get("txSignature") + ) + + +def format_gateway_clmm_result(action: str, result: dict[str, Any]) -> str: + """Format Gateway CLMM position management results into a human-readable string.""" + if action in ["open_position", "close_position", "collect_fees"]: + payload = result.get("result", {}) if isinstance(result, dict) else {} + if not isinstance(payload, dict): + return f"Gateway CLMM {action}: {payload}" + + tx_hash = _extract_transaction_hash(payload) + position_address = ( + payload.get("position_address") + or result.get("position_address") + or payload.get("nft_id") + ) + + lines = [f"Gateway CLMM {action.replace('_', ' ').title()} Result:"] + if position_address: + lines.append(f"Position: {position_address}") + if tx_hash: + lines.append(f"Transaction: {tx_hash}") + if not position_address and not tx_hash: + lines.append(str(payload)) + return "\n".join(lines) + + if action == "get_positions": + positions = result.get("result", []) + count = len(positions) if isinstance(positions, list) else 0 + return f"Gateway CLMM Positions ({count} found):\n\n{positions}" + + if action == "search": + search_result = result.get("result", {}) + positions = search_result.get("data", []) if isinstance(search_result, dict) else [] + pagination = result.get("pagination", {}) + filters = result.get("filters", {}) + return ( + f"Gateway CLMM Position Search Result:\n" + f"Total Positions Found: {len(positions)}\n" + f"Limit: {pagination.get('limit', 'N/A')}, Offset: {pagination.get('offset', 'N/A')}\n" + f"Refresh: {pagination.get('refresh', False)}\n" + f"Filters: {filters if filters else 'None'}\n\n" + f"Positions: {positions}" + ) + + return f"Gateway CLMM Result: {result}" + + def format_gateway_clmm_pool_result(action: str, result: dict[str, Any]) -> str: """Format gateway CLMM pool exploration results into a human-readable string.""" if action == "list_pools" and "pools_table" in result: diff --git a/mcp_servers/hummingbot_api/schemas.py b/mcp_servers/hummingbot_api/schemas.py index f7ebbfa6..2ffb7cec 100644 --- a/mcp_servers/hummingbot_api/schemas.py +++ b/mcp_servers/hummingbot_api/schemas.py @@ -685,3 +685,99 @@ class GatewayCLMMRequest(BaseModel): default=False, description="Return detailed table with more columns (default: False)" ) + + +class GatewayCLMMManageRequest(BaseModel): + """Request model for Gateway CLMM liquidity position management.""" + + action: Literal["open_position", "close_position", "collect_fees", "get_positions", "search"] = Field( + description="Action to perform: open_position, close_position, collect_fees, get_positions, or search" + ) + + connector: str | None = Field( + default=None, + description="CLMM connector name. Examples: 'meteora', 'raydium', 'uniswap'" + ) + + network: str | None = Field( + default=None, + description="Network ID in 'chain-network' format. Examples: 'solana-mainnet-beta', 'ethereum-mainnet'" + ) + + pool_address: str | None = Field( + default=None, + description="Pool contract address. Required for open_position and get_positions" + ) + + position_address: str | None = Field( + default=None, + description="Position NFT/address. Required for close_position and collect_fees" + ) + + lower_price: str | None = Field( + default=None, + description="Lower price bound for open_position" + ) + + upper_price: str | None = Field( + default=None, + description="Upper price bound for open_position" + ) + + base_token_amount: str | None = Field( + default=None, + description="Base token amount for open_position" + ) + + quote_token_amount: str | None = Field( + default=None, + description="Quote token amount for open_position" + ) + + slippage_pct: str | None = Field( + default="1.0", + description="Slippage percentage tolerance for open_position (default: 1.0)" + ) + + wallet_address: str | None = Field( + default=None, + description="Wallet address for mutating CLMM actions (optional, uses default wallet if omitted)" + ) + + extra_params: dict[str, Any] | None = Field( + default=None, + description="Connector-specific parameters, such as {'strategyType': 0} for Meteora" + ) + + trading_pair: str | None = Field( + default=None, + description="Trading pair filter for search action" + ) + + status: Literal["OPEN", "CLOSED"] | None = Field( + default=None, + description="Position status filter for search action" + ) + + position_addresses: list[str] | None = Field( + default=None, + description="Specific position addresses to filter in search action" + ) + + limit: int = Field( + default=50, + ge=1, + le=1000, + description="Maximum number of results for search action (default: 50, max: 1000)" + ) + + offset: int = Field( + default=0, + ge=0, + description="Pagination offset for search action" + ) + + refresh: bool = Field( + default=False, + description="Refresh position data from Gateway before returning search results" + ) diff --git a/mcp_servers/hummingbot_api/server.py b/mcp_servers/hummingbot_api/server.py index d1a2e61a..0b926fa2 100644 --- a/mcp_servers/hummingbot_api/server.py +++ b/mcp_servers/hummingbot_api/server.py @@ -13,6 +13,7 @@ format_active_bots_as_table, format_bot_logs_as_table, format_connector_result, + format_gateway_clmm_result, format_gateway_clmm_pool_result, format_gateway_config_result, format_gateway_container_result, @@ -23,6 +24,7 @@ from mcp_servers.hummingbot_api.middleware import GATEWAY_LOG_HINT, handle_errors from mcp_servers.hummingbot_api.schemas import ( GatewayCLMMRequest, + GatewayCLMMManageRequest, GatewayConfigRequest, GatewayContainerRequest, GatewaySwapRequest, @@ -41,7 +43,10 @@ manage_gateway_config as manage_gateway_config_impl, manage_gateway_container as manage_gateway_container_impl, ) -from mcp_servers.hummingbot_api.tools.gateway_clmm import explore_gateway_clmm_pools as explore_gateway_clmm_pools_impl +from mcp_servers.hummingbot_api.tools.gateway_clmm import ( + explore_gateway_clmm_pools as explore_gateway_clmm_pools_impl, + manage_gateway_clmm as manage_gateway_clmm_impl, +) from mcp_servers.hummingbot_api.tools.gateway_swap import manage_gateway_swaps as manage_gateway_swaps_impl from mcp_servers.hummingbot_api.tools.geckoterminal import explore_geckoterminal as explore_geckoterminal_impl from mcp_servers.hummingbot_api.tools import history as history_tools @@ -831,6 +836,54 @@ async def explore_dex_pools( return format_gateway_clmm_pool_result(action, result) +@mcp.tool() +@handle_errors("manage Gateway CLMM positions", GATEWAY_LOG_HINT) +async def manage_gateway_clmm( + action: Literal["open_position", "close_position", "collect_fees", "get_positions", "search"], + connector: str | None = None, + network: str | None = None, + pool_address: str | None = None, + position_address: str | None = None, + lower_price: str | None = None, + upper_price: str | None = None, + base_token_amount: str | None = None, + quote_token_amount: str | None = None, + slippage_pct: str | None = "1.0", + wallet_address: str | None = None, + extra_params: dict[str, Any] | None = None, + trading_pair: str | None = None, + status: Literal["OPEN", "CLOSED"] | None = None, + position_addresses: list[str] | None = None, + limit: int = 50, + offset: int = 0, + refresh: bool = False, +) -> str: + """Manage Gateway CLMM liquidity positions: open, close, collect fees, get positions, or search.""" + request = GatewayCLMMManageRequest( + action=action, + connector=connector, + network=network, + pool_address=pool_address, + position_address=position_address, + lower_price=lower_price, + upper_price=upper_price, + base_token_amount=base_token_amount, + quote_token_amount=quote_token_amount, + slippage_pct=slippage_pct, + wallet_address=wallet_address, + extra_params=extra_params, + trading_pair=trading_pair, + status=status, + position_addresses=position_addresses, + limit=limit, + offset=offset, + refresh=refresh, + ) + client = await hummingbot_client.get_client() + result = await manage_gateway_clmm_impl(client, request) + return format_gateway_clmm_result(action, result) + + # GeckoTerminal Tools @@ -933,6 +986,119 @@ async def run_backtest( return result.get("formatted_output", str(result)) +@mcp.tool() +@handle_errors("manage Gateway container", GATEWAY_LOG_HINT) +async def manage_gateway_container( + action: Literal["get_status", "start", "stop", "restart", "get_logs"], + config: dict[str, Any] | None = None, + tail: int | None = 100, +) -> str: + """Manage Gateway container lifecycle: get_status, start, stop, restart, get_logs.""" + request = GatewayContainerRequest( + action=action, + config=config, + tail=tail, + ) + client = await hummingbot_client.get_client() + result = await manage_gateway_container_impl(client, request) + return format_gateway_container_result(result) + + +@mcp.tool() +@handle_errors("manage Gateway config", GATEWAY_LOG_HINT) +async def manage_gateway_config( + resource_type: Literal["chains", "networks", "tokens", "connectors", "pools", "wallets"], + action: Literal["list", "get", "update", "add", "delete"], + network_id: str | None = None, + connector_name: str | None = None, + config_updates: dict[str, Any] | None = None, + token_address: str | None = None, + token_symbol: str | None = None, + token_decimals: int | None = None, + token_name: str | None = None, + search: str | None = None, + pool_type: str | None = None, + network: str | None = None, + pool_base: str | None = None, + pool_quote: str | None = None, + pool_address: str | None = None, + chain: str | None = None, + private_key: str | None = None, + wallet_address: str | None = None, +) -> str: + """Manage Gateway chains, networks, tokens, connectors, pools, and wallets.""" + request = GatewayConfigRequest( + resource_type=resource_type, + action=action, + network_id=network_id, + connector_name=connector_name, + config_updates=config_updates, + token_address=token_address, + token_symbol=token_symbol, + token_decimals=token_decimals, + token_name=token_name, + search=search, + pool_type=pool_type, + network=network, + pool_base=pool_base, + pool_quote=pool_quote, + pool_address=pool_address, + chain=chain, + private_key=private_key, + wallet_address=wallet_address, + ) + client = await hummingbot_client.get_client() + result = await manage_gateway_config_impl(client, request) + return format_gateway_config_result(result) + + +@mcp.tool() +@handle_errors("manage Gateway swaps", GATEWAY_LOG_HINT) +async def manage_gateway_swaps( + action: Literal["quote", "execute", "search", "get_status"], + connector: str | None = None, + network: str | None = None, + trading_pair: str | None = None, + side: Literal["BUY", "SELL"] | None = None, + amount: str | None = None, + slippage_pct: str | None = "1.0", + wallet_address: str | None = None, + transaction_hash: str | None = None, + search_network: str | None = None, + search_connector: str | None = None, + search_wallet_address: str | None = None, + search_trading_pair: str | None = None, + status: str | None = None, + start_time: int | None = None, + end_time: int | None = None, + limit: int | None = 50, + offset: int | None = 0, +) -> str: + """Manage Gateway swap quote, execute, search, and transaction status.""" + request = GatewaySwapRequest( + action=action, + connector=connector, + network=network, + trading_pair=trading_pair, + side=side, + amount=amount, + slippage_pct=slippage_pct, + wallet_address=wallet_address, + transaction_hash=transaction_hash, + search_network=search_network, + search_connector=search_connector, + search_wallet_address=search_wallet_address, + search_trading_pair=search_trading_pair, + status=status, + start_time=start_time, + end_time=end_time, + limit=limit, + offset=offset, + ) + client = await hummingbot_client.get_client() + result = await manage_gateway_swaps_impl(client, request) + return format_gateway_swap_result(action, result) + @mcp.tool() @handle_errors("manage backtest tasks") async def manage_backtest_tasks( diff --git a/mcp_servers/hummingbot_api/tools/gateway_clmm.py b/mcp_servers/hummingbot_api/tools/gateway_clmm.py index c379e3e7..a6e78973 100644 --- a/mcp_servers/hummingbot_api/tools/gateway_clmm.py +++ b/mcp_servers/hummingbot_api/tools/gateway_clmm.py @@ -8,15 +8,28 @@ For opening/closing LP positions, use `manage_executors` with `lp_executor` type. """ import logging +from decimal import Decimal, InvalidOperation from typing import Any from mcp_servers.hummingbot_api.exceptions import ToolError from mcp_servers.hummingbot_api.formatters.base import format_number, get_field -from mcp_servers.hummingbot_api.schemas import GatewayCLMMRequest +from mcp_servers.hummingbot_api.schemas import GatewayCLMMManageRequest, GatewayCLMMRequest logger = logging.getLogger("hummingbot-mcp") +def _parse_decimal(value: str | None, field_name: str, required: bool = False) -> Decimal | None: + if value is None: + if required: + raise ToolError(f"{field_name} is required") + return None + + try: + return Decimal(value) + except (InvalidOperation, ValueError) as exc: + raise ToolError(f"{field_name} must be a valid decimal string") from exc + + def format_pools_as_table(pools: list[dict[str, Any]]) -> str: """ Format pool data as a simplified table string. @@ -191,3 +204,143 @@ async def explore_gateway_clmm_pools(client: Any, request: GatewayCLMMRequest) - raise ToolError(f"Unknown action: {request.action}") +async def manage_gateway_clmm(client: Any, request: GatewayCLMMManageRequest) -> dict[str, Any]: + """ + Manage Gateway CLMM liquidity positions. + + Actions: + - open_position: Open a new concentrated liquidity position + - close_position: Close a position completely + - collect_fees: Collect accumulated fees from a position + - get_positions: List positions owned for a pool + - search: Search indexed CLMM positions + """ + if request.action == "open_position": + if not request.connector: + raise ToolError("connector is required for open_position action") + if not request.network: + raise ToolError("network is required for open_position action") + if not request.pool_address: + raise ToolError("pool_address is required for open_position action") + if not request.base_token_amount and not request.quote_token_amount: + raise ToolError("At least one of base_token_amount or quote_token_amount is required for open_position action") + + result = await client.gateway_clmm.open_position( + connector=request.connector, + network=request.network, + pool_address=request.pool_address, + lower_price=_parse_decimal(request.lower_price, "lower_price", required=True), + upper_price=_parse_decimal(request.upper_price, "upper_price", required=True), + base_token_amount=_parse_decimal(request.base_token_amount, "base_token_amount"), + quote_token_amount=_parse_decimal(request.quote_token_amount, "quote_token_amount"), + slippage_pct=_parse_decimal(request.slippage_pct, "slippage_pct"), + wallet_address=request.wallet_address, + extra_params=request.extra_params, + ) + return { + "action": "open_position", + "connector": request.connector, + "network": request.network, + "pool_address": request.pool_address, + "result": result, + } + + elif request.action == "close_position": + if not request.connector: + raise ToolError("connector is required for close_position action") + if not request.network: + raise ToolError("network is required for close_position action") + if not request.position_address: + raise ToolError("position_address is required for close_position action") + + result = await client.gateway_clmm.close_position( + connector=request.connector, + network=request.network, + position_address=request.position_address, + wallet_address=request.wallet_address, + ) + return { + "action": "close_position", + "connector": request.connector, + "network": request.network, + "position_address": request.position_address, + "result": result, + } + + elif request.action == "collect_fees": + if not request.connector: + raise ToolError("connector is required for collect_fees action") + if not request.network: + raise ToolError("network is required for collect_fees action") + if not request.position_address: + raise ToolError("position_address is required for collect_fees action") + + result = await client.gateway_clmm.collect_fees( + connector=request.connector, + network=request.network, + position_address=request.position_address, + wallet_address=request.wallet_address, + ) + return { + "action": "collect_fees", + "connector": request.connector, + "network": request.network, + "position_address": request.position_address, + "result": result, + } + + elif request.action == "get_positions": + if not request.connector: + raise ToolError("connector is required for get_positions action") + if not request.network: + raise ToolError("network is required for get_positions action") + if not request.pool_address: + raise ToolError("pool_address is required for get_positions action") + + result = await client.gateway_clmm.get_positions_owned( + connector=request.connector, + network=request.network, + pool_address=request.pool_address, + wallet_address=request.wallet_address, + ) + return { + "action": "get_positions", + "connector": request.connector, + "network": request.network, + "pool_address": request.pool_address, + "result": result, + } + + elif request.action == "search": + search_params = { + "limit": request.limit, + "offset": request.offset, + "refresh": request.refresh, + } + if request.network: + search_params["network"] = request.network + if request.connector: + search_params["connector"] = request.connector + if request.wallet_address: + search_params["wallet_address"] = request.wallet_address + if request.trading_pair: + search_params["trading_pair"] = request.trading_pair + if request.status: + search_params["status"] = request.status + if request.position_addresses: + search_params["position_addresses"] = request.position_addresses + + result = await client.gateway_clmm.search_positions(**search_params) + return { + "action": "search", + "filters": {k: v for k, v in search_params.items() if k not in ["limit", "offset", "refresh"]}, + "pagination": { + "limit": search_params["limit"], + "offset": search_params["offset"], + "refresh": search_params["refresh"], + }, + "result": result, + } + + else: + raise ToolError(f"Unknown action: {request.action}") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..c893e386 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,4 @@ +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) diff --git a/tests/test_hummingbot_mcp_gateway_tools.py b/tests/test_hummingbot_mcp_gateway_tools.py new file mode 100644 index 00000000..7ca742d0 --- /dev/null +++ b/tests/test_hummingbot_mcp_gateway_tools.py @@ -0,0 +1,130 @@ +import asyncio +from decimal import Decimal + +from mcp_servers.hummingbot_api import server +from mcp_servers.hummingbot_api.schemas import GatewayCLMMManageRequest +from mcp_servers.hummingbot_api.tools.gateway_clmm import manage_gateway_clmm + + +class _DummyHummingbotClient: + pass + + +def test_server_registers_gateway_container_with_formatter_contract(monkeypatch): + async def get_client(): + return _DummyHummingbotClient() + + async def impl(client, request): + assert isinstance(client, _DummyHummingbotClient) + assert request.action == "get_status" + return {"action": request.action, "status": {"running": True}} + + formatter_calls = [] + + def formatter(result): + formatter_calls.append(result) + return "formatted container" + + monkeypatch.setattr(server.hummingbot_client, "get_client", get_client) + monkeypatch.setattr(server, "manage_gateway_container_impl", impl) + monkeypatch.setattr(server, "format_gateway_container_result", formatter) + + result = asyncio.run(server.manage_gateway_container(action="get_status")) + + assert result == "formatted container" + assert formatter_calls == [{"action": "get_status", "status": {"running": True}}] + + +def test_server_registers_gateway_config_with_formatter_contract(monkeypatch): + async def get_client(): + return _DummyHummingbotClient() + + async def impl(client, request): + assert isinstance(client, _DummyHummingbotClient) + assert request.resource_type == "chains" + assert request.action == "list" + return { + "resource_type": request.resource_type, + "action": request.action, + "result": {"chains": []}, + } + + formatter_calls = [] + + def formatter(result): + formatter_calls.append(result) + return "formatted config" + + monkeypatch.setattr(server.hummingbot_client, "get_client", get_client) + monkeypatch.setattr(server, "manage_gateway_config_impl", impl) + monkeypatch.setattr(server, "format_gateway_config_result", formatter) + + result = asyncio.run( + server.manage_gateway_config(resource_type="chains", action="list") + ) + + assert result == "formatted config" + assert formatter_calls == [ + {"resource_type": "chains", "action": "list", "result": {"chains": []}} + ] + + +def test_server_registers_gateway_clmm_tool(monkeypatch): + async def get_client(): + return _DummyHummingbotClient() + + async def impl(client, request): + assert isinstance(client, _DummyHummingbotClient) + assert request.action == "search" + assert request.status == "OPEN" + return {"action": request.action, "result": {"data": []}} + + monkeypatch.setattr(server.hummingbot_client, "get_client", get_client) + monkeypatch.setattr(server, "manage_gateway_clmm_impl", impl) + monkeypatch.setattr( + server, + "format_gateway_clmm_result", + lambda action, result: f"{action}: formatted", + ) + + result = asyncio.run(server.manage_gateway_clmm(action="search", status="OPEN")) + + assert result == "search: formatted" + + +def test_manage_gateway_clmm_opens_position_with_decimal_amounts(): + calls = {} + + class GatewayCLMM: + async def open_position(self, **kwargs): + calls.update(kwargs) + return {"position_address": "pos-1", "transaction_hash": "tx-1"} + + class Client: + gateway_clmm = GatewayCLMM() + + request = GatewayCLMMManageRequest( + action="open_position", + connector="meteora", + network="solana-mainnet-beta", + pool_address="pool-1", + lower_price="10.5", + upper_price="12.5", + base_token_amount="1.25", + quote_token_amount="50", + slippage_pct="0.5", + extra_params={"strategyType": 0}, + ) + + result = asyncio.run(manage_gateway_clmm(Client(), request)) + + assert result["action"] == "open_position" + assert calls["connector"] == "meteora" + assert calls["network"] == "solana-mainnet-beta" + assert calls["pool_address"] == "pool-1" + assert calls["lower_price"] == Decimal("10.5") + assert calls["upper_price"] == Decimal("12.5") + assert calls["base_token_amount"] == Decimal("1.25") + assert calls["quote_token_amount"] == Decimal("50") + assert calls["slippage_pct"] == Decimal("0.5") + assert calls["extra_params"] == {"strategyType": 0}