Skip to main content
Glama
tools.go24.1 kB
package tools import ( "context" "encoding/json" "fmt" "log/slog" "strconv" "strings" "time" "github.com/luno/luno-go" "github.com/luno/luno-go/decimal" "github.com/luno/luno-mcp/internal/config" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) // Error messages const ( ErrAPICredentialsRequired = "API credentials are required for this operation. Please set LUNO_API_KEY_ID and LUNO_API_SECRET environment variables." ErrTradingPairRequired = "Trading pair is required" ErrTradingPairDesc = "Trading pair (e.g., XBTZAR)" ) // Tool IDs const ( GetBalancesToolID = "get_balances" GetTickerToolID = "get_ticker" GetTickersToolID = "get_tickers" GetOrderBookToolID = "get_order_book" CreateOrderToolID = "create_order" CancelOrderToolID = "cancel_order" ListOrdersToolID = "list_orders" ListTransactionsToolID = "list_transactions" GetTransactionToolID = "get_transaction" ListTradesToolID = "list_trades" GetCandlesToolID = "get_candles" GetMarketsInfoToolID = "get_markets_info" ) // ===== Balance Tools ===== // NewGetBalancesTool creates a new tool for getting account balances func NewGetBalancesTool() mcp.Tool { return mcp.NewTool( GetBalancesToolID, mcp.WithDescription("Get balances for all Luno accounts"), ) } // HandleGetBalances handles the get_balances tool func HandleGetBalances(cfg *config.Config) server.ToolHandlerFunc { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { if !cfg.IsAuthenticated { return mcp.NewToolResultError(ErrAPICredentialsRequired), nil } balances, err := cfg.LunoClient.GetBalances(ctx, &luno.GetBalancesRequest{}) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get balances: %v", err)), nil } // Enhance the response with additional information type EnhancedBalance struct { AccountID string `json:"account_id"` Asset string `json:"asset"` Balance string `json:"balance"` Reserved string `json:"reserved"` Unconfirmed string `json:"unconfirmed"` Name string `json:"name"` } enhancedBalances := make([]EnhancedBalance, 0, len(balances.Balance)) for _, balance := range balances.Balance { enhancedBalances = append(enhancedBalances, EnhancedBalance{ AccountID: balance.AccountId, Asset: balance.Asset, Balance: balance.Balance.String(), Reserved: balance.Reserved.String(), Unconfirmed: balance.Unconfirmed.String(), Name: balance.Name, }) } resultJSON, err := json.MarshalIndent(enhancedBalances, "", " ") if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal balances: %v", err)), nil } return mcp.NewToolResultText(string(resultJSON)), nil } } // ===== Market Tools ===== // NewGetTickerTool creates a new tool for getting ticker information func NewGetTickerTool() mcp.Tool { return mcp.NewTool( GetTickerToolID, mcp.WithDescription("Get ticker information for a trading pair"), mcp.WithString( "pair", mcp.Required(), mcp.Description(ErrTradingPairDesc), ), ) } // HandleGetTicker handles the get_ticker tool func HandleGetTicker(cfg *config.Config) server.ToolHandlerFunc { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { pair, err := request.RequireString("pair") if err != nil { return mcp.NewToolResultErrorFromErr("getting pair from request", err), nil } // Normalize currency pair pair = normalizeCurrencyPair(pair) ticker, err := cfg.LunoClient.GetTicker(ctx, &luno.GetTickerRequest{ Pair: pair, }) if err != nil { return mcp.NewToolResultErrorFromErr("getting ticker", err), nil } resultJSON, err := json.MarshalIndent(ticker, "", " ") if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal ticker: %v", err)), nil } return mcp.NewToolResultText(string(resultJSON)), nil } } // NewGetOrderBookTool creates a new tool for getting the order book func NewGetOrderBookTool() mcp.Tool { return mcp.NewTool( GetOrderBookToolID, mcp.WithDescription("Get order book for a trading pair"), mcp.WithString( "pair", mcp.Required(), mcp.Description(ErrTradingPairDesc), ), ) } // HandleGetOrderBook handles the get_order_book tool func HandleGetOrderBook(cfg *config.Config) server.ToolHandlerFunc { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { pair, err := request.RequireString("pair") if err != nil { return mcp.NewToolResultErrorFromErr("getting pair from request", err), nil } // Normalize currency pair pair = normalizeCurrencyPair(pair) orderBook, err := cfg.LunoClient.GetOrderBook(ctx, &luno.GetOrderBookRequest{ Pair: pair, }) if err != nil { return mcp.NewToolResultErrorFromErr("getting order book", err), nil } resultJSON, err := json.MarshalIndent(orderBook, "", " ") if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal order book: %v", err)), nil } return mcp.NewToolResultText(string(resultJSON)), nil } } // NewGetTickersTool creates a new tool for getting ticker information for all currency pairs func NewGetTickersTool() mcp.Tool { return mcp.NewTool( GetTickersToolID, mcp.WithDescription("List tickers for all currency pairs"), mcp.WithString( "pair", mcp.Description("Return tickers for multiple markets (e.g., XBTZAR,ETHZAR)"), ), ) } // HandleGetTickers handles the get_tickers tool func HandleGetTickers(cfg *config.Config) server.ToolHandlerFunc { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { pairsStr := request.GetString("pair", "") var pairs []string if pairsStr != "" { pairs = strings.Split(pairsStr, ",") for i, p := range pairs { pairs[i] = normalizeCurrencyPair(p) } } tickers, err := cfg.LunoClient.GetTickers(ctx, &luno.GetTickersRequest{ Pair: pairs, }) if err != nil { return mcp.NewToolResultErrorFromErr("getting tickers", err), nil } resultJSON, err := json.MarshalIndent(tickers, "", " ") if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal tickers: %v", err)), nil } return mcp.NewToolResultText(string(resultJSON)), nil } } // NewGetCandlesTool creates a new tool for getting candlestick market data func NewGetCandlesTool() mcp.Tool { return mcp.NewTool( GetCandlesToolID, mcp.WithDescription("Get candlestick market data for a currency pair"), mcp.WithString( "pair", mcp.Required(), mcp.Description(ErrTradingPairDesc), ), mcp.WithNumber( "since", mcp.Description("Filter to candles starting on or after this timestamp (Unix milliseconds). Defaults to 24 hours ago."), ), mcp.WithNumber( "duration", mcp.Required(), mcp.Description("Candle duration in seconds (e.g., 60 for 1m, 300 for 5m, 3600 for 1h)"), ), ) } // HandleGetCandles handles the get_candles tool func HandleGetCandles(cfg *config.Config) server.ToolHandlerFunc { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { pair, err := request.RequireString("pair") if err != nil { return mcp.NewToolResultErrorFromErr("getting pair from request", err), nil } pair = normalizeCurrencyPair(pair) sinceFloat := request.GetFloat("since", 0) var since luno.Time if sinceFloat == 0 { // Default to 24 hours ago if since is not provided or is 0 since = luno.Time(time.Now().Add(-24 * time.Hour)) } else { since = luno.Time(time.UnixMilli(int64(sinceFloat))) } durationFloat, err := request.RequireFloat("duration") if err != nil { return mcp.NewToolResultErrorFromErr("getting duration from request", err), nil } duration := int64(durationFloat) candles, err := cfg.LunoClient.GetCandles(ctx, &luno.GetCandlesRequest{ Pair: pair, Since: since, Duration: duration, }) if err != nil { return mcp.NewToolResultErrorFromErr("getting candles", err), nil } resultJSON, err := json.MarshalIndent(candles, "", " ") if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal candles: %v", err)), nil } return mcp.NewToolResultText(string(resultJSON)), nil } } // NewGetMarketsInfoTool creates a new tool for getting market information func NewGetMarketsInfoTool() mcp.Tool { return mcp.NewTool( GetMarketsInfoToolID, mcp.WithDescription("List all supported markets parameter information"), mcp.WithString( "pair", mcp.Description("List of market pairs to return (e.g., XBTZAR,ETHZAR)"), ), ) } // HandleGetMarketsInfo handles the get_markets_info tool func HandleGetMarketsInfo(cfg *config.Config) server.ToolHandlerFunc { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { pairsStr := request.GetString("pair", "") var pairs []string if pairsStr != "" { pairs = strings.Split(pairsStr, ",") for i, p := range pairs { pairs[i] = normalizeCurrencyPair(p) } } markets, err := cfg.LunoClient.Markets(ctx, &luno.MarketsRequest{ Pair: pairs, }) if err != nil { return mcp.NewToolResultErrorFromErr("getting markets info", err), nil } resultJSON, err := json.MarshalIndent(markets, "", " ") if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal markets info: %v", err)), nil } return mcp.NewToolResultText(string(resultJSON)), nil } } // ===== Trading Tools ===== // NewCreateOrderTool creates a new tool for creating limit orders func NewCreateOrderTool() mcp.Tool { return mcp.NewTool( CreateOrderToolID, mcp.WithDescription("Create a new limit order"), mcp.WithString( "pair", mcp.Required(), mcp.Description("Trading pair (e.g., XBTZAR)"), ), mcp.WithString( "type", mcp.Required(), mcp.Description("Order type (BUY or SELL)"), mcp.Enum("BUY", "SELL"), ), mcp.WithString( "volume", mcp.Required(), mcp.Description("Order volume (amount of cryptocurrency to buy or sell)"), ), mcp.WithString( "price", mcp.Required(), mcp.Description("Limit price as a decimal string"), ), ) } // HandleCreateOrder handles the create_order tool for limit orders // TODO: Add HandleCreateMarketOrder function for market orders func HandleCreateOrder(cfg *config.Config) server.ToolHandlerFunc { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { if !cfg.IsAuthenticated { return mcp.NewToolResultError(ErrAPICredentialsRequired), nil } pair, err := request.RequireString("pair") if err != nil { return mcp.NewToolResultErrorFromErr("getting pair from request", err), nil } slog.Debug("Processing trading pair", "originalPair", pair) // Normalize the pair - this should handle BTC->XBT conversion automatically pair = normalizeCurrencyPair(pair) slog.Debug("Normalized trading pair", "originalPair", pair, "normalizedPair", pair) orderType, err := request.RequireString("type") if err != nil { return mcp.NewToolResultErrorFromErr("getting type from request", err), nil } if orderType != "BUY" && orderType != "SELL" { return mcp.NewToolResultError("Order type must be 'BUY' or 'SELL'"), nil } volumeStr, err := request.RequireString("volume") if err != nil { return mcp.NewToolResultErrorFromErr("getting volume from request", err), nil } priceStr, err := request.RequireString("price") if err != nil { return mcp.NewToolResultErrorFromErr("getting price from request", err), nil } // Validate numeric values volumeDec, err := decimal.NewFromString(volumeStr) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Invalid volume format: %v", err)), nil } priceDec, err := decimal.NewFromString(priceStr) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Invalid price format: %v", err)), nil } // Map BUY/SELL to BID/ASK for limit orders var lunoOrderType luno.OrderType if orderType == "BUY" { lunoOrderType = luno.OrderTypeBid } else { // SELL lunoOrderType = luno.OrderTypeAsk } // Get market info - we already validated the pair, but this provides additional info marketInfoString, err := GetMarketInfo(ctx, cfg, pair) if err != nil { slog.Error("Failed to get market info during order creation", "pair", pair, "error", err) return mcp.NewToolResultError(fmt.Sprintf("Unable to create order: Failed to retrieve market information for pair %s. Details: %v", pair, err)), nil } // Log the request parameters for debugging slog.Info("Creating order", "pair", pair, "type", lunoOrderType, "volume", volumeDec.String(), "price", priceDec.String()) // Create the limit order createReq := &luno.PostLimitOrderRequest{ Pair: pair, Type: lunoOrderType, Volume: volumeDec, Price: priceDec, } order, err := cfg.LunoClient.PostLimitOrder(ctx, createReq) if err != nil { // If the order fails despite our validation, provide detailed error information errorMsg := fmt.Sprintf("Failed to create limit order: %v\n\n"+ "Here's what we know about this market:\n%s\n\n"+ "This may be due to insufficient balance, market conditions, or API limits.", err, marketInfoString) return mcp.NewToolResultError(errorMsg), nil } // Order succeeded resultJSON, err := json.MarshalIndent(order, "", " ") if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal order result: %v", err)), nil } successMsg := fmt.Sprintf("Order created successfully!\n\n%s\n\n%s", string(resultJSON), marketInfoString) return mcp.NewToolResultText(successMsg), nil } } // NewCancelOrderTool creates a new tool for canceling orders func NewCancelOrderTool() mcp.Tool { return mcp.NewTool( CancelOrderToolID, mcp.WithDescription("Cancel an order"), mcp.WithString( "order_id", mcp.Required(), mcp.Description("Order ID to cancel"), ), ) } // HandleCancelOrder handles the cancel_order tool func HandleCancelOrder(cfg *config.Config) server.ToolHandlerFunc { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { if !cfg.IsAuthenticated { return mcp.NewToolResultError(ErrAPICredentialsRequired), nil } orderID, err := request.RequireString("order_id") if err != nil { return mcp.NewToolResultErrorFromErr("getting order_id from request", err), nil } result, err := cfg.LunoClient.StopOrder(ctx, &luno.StopOrderRequest{ OrderId: orderID, }) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to cancel order: %v", err)), nil } resultJSON, err := json.MarshalIndent(result, "", " ") if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal result: %v", err)), nil } return mcp.NewToolResultText(string(resultJSON)), nil } } // NewListOrdersTool creates a new tool for listing orders func NewListOrdersTool() mcp.Tool { return mcp.NewTool( ListOrdersToolID, mcp.WithDescription("List open orders"), mcp.WithString( "pair", mcp.Description("Trading pair (e.g., XBTZAR)"), ), mcp.WithNumber( "limit", mcp.Description("Maximum number of orders to return (default: 100)"), ), ) } // HandleListOrders handles the list_orders tool func HandleListOrders(cfg *config.Config) server.ToolHandlerFunc { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { if !cfg.IsAuthenticated { return mcp.NewToolResultError(ErrAPICredentialsRequired), nil } // Get the pair if provided, otherwise it will be an empty string. // An empty pair string will result in fetching orders for all pairs. pair := request.GetString("pair", "") if pair != "" { pair = normalizeCurrencyPair(pair) } // Default to 100 if not present limit := request.GetFloat("limit", 100) listReq := &luno.ListOrdersRequest{ Pair: pair, Limit: int64(limit), } orders, err := cfg.LunoClient.ListOrders(ctx, listReq) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to list orders: %v", err)), nil } resultJSON, err := json.MarshalIndent(orders, "", " ") if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal orders: %v", err)), nil } return mcp.NewToolResultText(string(resultJSON)), nil } } // ===== Transaction Tools ===== // NewListTransactionsTool creates a new tool for listing transactions func NewListTransactionsTool() mcp.Tool { return mcp.NewTool( ListTransactionsToolID, mcp.WithDescription("List transactions for an account"), mcp.WithString( "account_id", mcp.Required(), mcp.Description("Account ID"), ), mcp.WithNumber( "min_row", mcp.Description("Minimum row ID to return (for pagination, inclusive)"), ), mcp.WithNumber( "max_row", mcp.Description("Maximum row ID to return (for pagination, exclusive)"), ), ) } // HandleListTransactions handles the list_transactions tool func HandleListTransactions(cfg *config.Config) server.ToolHandlerFunc { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { if !cfg.IsAuthenticated { return mcp.NewToolResultError(ErrAPICredentialsRequired), nil } accountIDStr, err := request.RequireString("account_id") if err != nil { return mcp.NewToolResultErrorFromErr("getting account_id from request", err), nil } // Convert account ID from string to int64 accountID, err := strconv.ParseInt(accountIDStr, 10, 64) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Invalid account ID format: %v. Please provide a valid numeric account ID.", err)), nil } listReq := &luno.ListTransactionsRequest{ Id: accountID, } // Default to 1 if not present minRow := request.GetInt("min_row", 1) listReq.MinRow = int64(minRow) // Default to 100 if not present maxRow := request.GetInt("max_row", 100) listReq.MaxRow = int64(maxRow) transactions, err := cfg.LunoClient.ListTransactions(ctx, listReq) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to list transactions: %v", err)), nil } resultJSON, err := json.MarshalIndent(transactions, "", " ") if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal transactions: %v", err)), nil } return mcp.NewToolResultText(string(resultJSON)), nil } } // NewGetTransactionTool creates a new tool for getting a specific transaction func NewGetTransactionTool() mcp.Tool { return mcp.NewTool( GetTransactionToolID, mcp.WithDescription("Get details of a specific transaction"), mcp.WithString( "account_id", mcp.Required(), mcp.Description("Account ID"), ), mcp.WithString( "transaction_id", mcp.Required(), mcp.Description("Transaction ID"), ), ) } // HandleGetTransaction handles the get_transaction tool func HandleGetTransaction(cfg *config.Config) server.ToolHandlerFunc { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { if !cfg.IsAuthenticated { return mcp.NewToolResultError(ErrAPICredentialsRequired), nil } accountIDStr, err := request.RequireString("account_id") if err != nil { return mcp.NewToolResultErrorFromErr("getting account_id from request", err), nil } // Convert account ID from string to int64 accountID, err := strconv.ParseInt(accountIDStr, 10, 64) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Invalid account ID format: %v. Please provide a valid numeric account ID.", err)), nil } transactionIDStr, err := request.RequireString("transaction_id") if err != nil { return mcp.NewToolResultErrorFromErr("getting transaction_id from request", err), nil } // Attempt to convert transaction ID to int64 for comparison transactionID, err := strconv.ParseInt(transactionIDStr, 10, 64) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Invalid transaction ID format: %v. Please provide a valid numeric transaction ID.", err)), nil } // Get the list of transactions with MinRow and MaxRow listReq := &luno.ListTransactionsRequest{ Id: accountID, MinRow: 0, // Start from the beginning MaxRow: 1000, // Use a reasonable max to find the transaction } transactions, err := cfg.LunoClient.ListTransactions(ctx, listReq) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get transactions: %v", err)), nil } // Find the specific transaction var transaction *luno.Transaction for _, txn := range transactions.Transactions { if txn.RowIndex == transactionID { transaction = &txn break } } if transaction == nil { return mcp.NewToolResultError(fmt.Sprintf("Transaction not found: %s", transactionIDStr)), nil } resultJSON, err := json.MarshalIndent(transaction, "", " ") if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal transaction: %v", err)), nil } return mcp.NewToolResultText(string(resultJSON)), nil } } // ===== Trades Tools ===== // NewListTradesTool creates a new tool for listing trades func NewListTradesTool() mcp.Tool { return mcp.NewTool( ListTradesToolID, mcp.WithDescription("List recent trades for a currency pair"), mcp.WithString( "pair", mcp.Required(), mcp.Description(ErrTradingPairDesc), ), mcp.WithString( "since", mcp.Description("Fetch trades executed after this timestamp (Unix milliseconds)"), ), ) } // HandleListTrades handles the list_trades tool func HandleListTrades(cfg *config.Config) server.ToolHandlerFunc { return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // This is a public endpoint, so no authentication check is needed here. // However, the LunoClient.ListTrades method might still require authentication // depending on the underlying luno-go library implementation. // For now, we assume it can be called unauthenticated. pair, err := request.RequireString("pair") if err != nil { return mcp.NewToolResultErrorFromErr("getting pair from request", err), nil } // Normalize currency pair pair = normalizeCurrencyPair(pair) req := &luno.ListTradesRequest{ Pair: pair, } sinceStr := request.GetString("since", "") if sinceStr != "" { // Try to parse the since timestamp sinceInt, err := strconv.ParseInt(sinceStr, 10, 64) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Invalid 'since' timestamp format: %v. Please provide a valid Unix millisecond timestamp.", err)), nil } req.Since = luno.Time(time.UnixMilli(sinceInt)) } trades, err := cfg.LunoClient.ListTrades(ctx, req) if err != nil { return mcp.NewToolResultErrorFromErr("listing trades", err), nil } resultJSON, err := json.MarshalIndent(trades, "", " ") if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal trades: %v", err)), nil } return mcp.NewToolResultText(string(resultJSON)), nil } } // ===== Helper Functions ===== // normalizeCurrencyPair converts common currency pair formats to Luno's expected format func normalizeCurrencyPair(pair string) string { // Log input for debugging originalPair := pair // Remove any separators that might be in the pair pair = strings.Replace(pair, "-", "", -1) pair = strings.Replace(pair, "_", "", -1) pair = strings.Replace(pair, "/", "", -1) pair = strings.ToUpper(pair) // Apply currency code standardization // Known mappings between common symbols and Luno's expected format currencyMappings := map[string]string{ "BTC": "XBT", // Bitcoin is XBT on Luno "BITCOIN": "XBT", // Add other mappings if needed in the future } // Apply all mappings for common, luno := range currencyMappings { pair = strings.Replace(pair, common, luno, -1) } // Log the normalization for debugging slog.Debug("Currency pair normalization", "original", originalPair, "normalized", pair) return pair }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/luno/luno-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server