diff --git a/bin/fxt/src/commands/mcp.rs b/bin/fxt/src/commands/mcp.rs index 09dbcfb3d..b401cd2fc 100644 --- a/bin/fxt/src/commands/mcp.rs +++ b/bin/fxt/src/commands/mcp.rs @@ -6,6 +6,7 @@ use anyhow::Result; use clap::Parser; +use crate::grpc::FoxhuntClient; use crate::mcp::server::McpServer; /// Start MCP server (JSON-RPC 2.0 on stdin/stdout) @@ -24,7 +25,8 @@ impl McpCommand { /// Start the MCP server that exposes Foxhunt operations as tools /// for LLM agents (e.g. Claude, Cursor). pub async fn execute(&self) -> Result<()> { - let server = McpServer::new(self.api_url.clone()); + let client = FoxhuntClient::connect(&self.api_url).await?; + let server = McpServer::new(self.api_url.clone(), client); server.run().await } } diff --git a/bin/fxt/src/mcp/server.rs b/bin/fxt/src/mcp/server.rs index 829406859..573e62859 100644 --- a/bin/fxt/src/mcp/server.rs +++ b/bin/fxt/src/mcp/server.rs @@ -2,10 +2,13 @@ //! //! Reads one JSON object per line from stdin, dispatches to the //! appropriate handler, and writes the response to stdout. +//! Tool handlers make real gRPC calls to the API Gateway via [`FoxhuntClient`]. use serde_json::{json, Value}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use crate::grpc::FoxhuntClient; + use super::protocol::{ error_response, success_response, JsonRpcRequest, JsonRpcResponse, INVALID_PARAMS, INVALID_REQUEST, METHOD_NOT_FOUND, PARSE_ERROR, @@ -14,14 +17,16 @@ use super::tools; /// MCP server that exposes Foxhunt operations as tools. pub struct McpServer { - /// API Gateway URL used for future gRPC calls. + /// API Gateway URL (for diagnostic logging). api_url: String, + /// gRPC client connected to the API Gateway. + client: FoxhuntClient, } impl McpServer { /// Create a new MCP server targeting the given API endpoint. - pub fn new(api_url: String) -> Self { - Self { api_url } + pub fn new(api_url: String, client: FoxhuntClient) -> Self { + Self { api_url, client } } /// Run the server loop: read JSON lines from stdin, write responses to stdout. @@ -140,9 +145,6 @@ impl McpServer { } /// Handle `tools/call` -- execute a tool and return the result. - /// - /// Tools return a "not implemented" error until the gRPC client - /// integration is wired up. async fn handle_tools_call(&self, id: Value, params: &Value) -> JsonRpcResponse { let Some(tool_name) = params.get("name").and_then(Value::as_str) else { return error_response(id, INVALID_PARAMS, "missing 'name' in tools/call params"); @@ -194,86 +196,1406 @@ impl McpServer { /// Execute a tool by name with the given arguments. /// - /// All tools currently return a "not implemented" error because the - /// gRPC client integration is not yet wired up. Tool *registrations* - /// remain so that `tools/list` still advertises them; callers get an - /// honest error instead of fake data. + /// Each tool maps to a real gRPC call against the API Gateway. async fn execute_tool( &self, tool_name: &str, - _arguments: &Value, + arguments: &Value, ) -> Result { - // Every known tool maps to a specific gRPC endpoint that is not yet - // connected. Return a structured error so MCP clients can distinguish - // "not implemented" from a transient failure. - let grpc_method = match tool_name { + match tool_name { // ── Service ───────────────────────────────────────────── - "fxt_service_list" => "ServiceDiscovery/ListServices", - "fxt_service_status" => "ServiceDiscovery/GetServiceStatus", - "fxt_service_health" => "Health/Check", + "fxt_service_list" => self.tool_service_list().await, + "fxt_service_status" => self.tool_service_status(arguments).await, + "fxt_service_health" => self.tool_service_health().await, // ── Training ──────────────────────────────────────────── - "fxt_train_start" => "MLTraining/StartTraining", - "fxt_train_stop" => "MLTraining/StopTraining", - "fxt_train_status" => "MLTraining/GetTrainingStatus", - "fxt_train_list" => "MLTraining/ListTrainingJobs", + "fxt_train_start" => self.tool_train_start(arguments).await, + "fxt_train_stop" => self.tool_train_stop(arguments).await, + "fxt_train_status" => self.tool_train_status(arguments).await, + "fxt_train_list" => self.tool_train_list().await, // ── Trading ───────────────────────────────────────────── - "fxt_trade_positions" => "TradingService/GetPositions", - "fxt_trade_orders" => "TradingService/GetOrders", - "fxt_trade_account" => "TradingService/GetAccountState", - "fxt_trade_submit" => "TradingService/SubmitOrder", + "fxt_trade_positions" => self.tool_trade_positions().await, + "fxt_trade_orders" => self.tool_trade_orders(arguments).await, + "fxt_trade_account" => self.tool_trade_account().await, + "fxt_trade_submit" => self.tool_trade_submit(arguments).await, // ── Models ────────────────────────────────────────────── - "fxt_model_list" => "MLService/ListModels", - "fxt_model_status" => "MLService/GetModelStatus", - "fxt_model_predict" => "MLService/Predict", - "fxt_model_ensemble" => "MLService/GetEnsembleVote", + "fxt_model_list" => self.tool_model_list().await, + "fxt_model_status" => self.tool_model_status(arguments).await, + "fxt_model_predict" => self.tool_model_predict(arguments).await, + "fxt_model_ensemble" => self.tool_model_ensemble(arguments).await, // ── Broker ────────────────────────────────────────────── - "fxt_broker_status" => "BrokerGateway/GetSessionStatus", - "fxt_broker_connect" => "BrokerGateway/Connect", + "fxt_broker_status" => self.tool_broker_status().await, + "fxt_broker_connect" => self.tool_broker_connect().await, // ── Risk ──────────────────────────────────────────────── - "fxt_risk_status" => "RiskService/GetRiskStatus", - "fxt_risk_limits" => "RiskService/GetLimits", - "fxt_risk_drawdown" => "RiskService/GetDrawdown", - "fxt_risk_emergency" => "RiskService/EmergencyAction", + "fxt_risk_status" => self.tool_risk_status().await, + "fxt_risk_limits" => self.tool_risk_limits().await, + "fxt_risk_drawdown" => self.tool_risk_drawdown().await, + "fxt_risk_emergency" => self.tool_risk_emergency(arguments).await, // ── Data ──────────────────────────────────────────────── - "fxt_data_status" => "DataAcquisition/GetStatus", - "fxt_data_feeds" => "DataAcquisition/ListFeeds", + "fxt_data_status" => self.tool_data_status().await, + "fxt_data_feeds" => self.tool_data_feeds().await, // ── Cluster ───────────────────────────────────────────── - "fxt_cluster_status" => "Monitoring/GetClusterStatus", - "fxt_cluster_resources" => "Monitoring/GetResources", + "fxt_cluster_status" => self.tool_cluster_status().await, + "fxt_cluster_resources" => self.tool_cluster_resources().await, // ── Agent ─────────────────────────────────────────────── - "fxt_agent_start" => "TradingAgent/StartAgent", - "fxt_agent_stop" => "TradingAgent/StopAgent", - "fxt_agent_status" => "TradingAgent/GetAgentStatus", + "fxt_agent_start" => self.tool_agent_start().await, + "fxt_agent_stop" => self.tool_agent_stop().await, + "fxt_agent_status" => self.tool_agent_status().await, // ── Config ────────────────────────────────────────────── - "fxt_config_get" => "ConfigService/GetConfig", - "fxt_config_set" => "ConfigService/SetConfig", + "fxt_config_get" => self.tool_config_get(arguments).await, + "fxt_config_set" => self.tool_config_set(arguments).await, // ── Tune ──────────────────────────────────────────────── - "fxt_tune_start" => "MLTraining/StartHyperopt", - "fxt_tune_status" => "MLTraining/GetHyperoptStatus", + "fxt_tune_start" => self.tool_tune_start(arguments).await, + "fxt_tune_status" => self.tool_tune_status(arguments).await, - _ => return Err(anyhow::anyhow!("unknown tool: {tool_name}")), + _ => Err(anyhow::anyhow!("unknown tool: {tool_name}")), + } + } + + // ══════════════════════════════════════════════════════════════════ + // Argument helpers + // ══════════════════════════════════════════════════════════════════ + + /// Extract a required string argument. + fn arg_str<'args>(args: &'args Value, key: &str) -> Result<&'args str, anyhow::Error> { + args.get(key) + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing required argument: {key}")) + } + + /// Extract an optional string argument. + fn arg_str_opt<'args>(args: &'args Value, key: &str) -> Option<&'args str> { + args.get(key).and_then(Value::as_str) + } + + /// Extract an optional f64 argument. + fn arg_f64_opt(args: &Value, key: &str) -> Option { + args.get(key).and_then(Value::as_f64) + } + + // ══════════════════════════════════════════════════════════════════ + // Service tools + // ══════════════════════════════════════════════════════════════════ + + async fn tool_service_list(&self) -> Result { + let resp = self + .client + .monitoring() + .get_system_status(crate::proto::monitoring::GetSystemStatusRequest { + service_names: vec![], + }) + .await? + .into_inner(); + + let overall = resp.overall_status.unwrap_or_default(); + let services: Vec = resp + .service_statuses + .iter() + .map(|s| { + json!({ + "name": s.service_name, + "health": service_health_str(s.health), + "state": service_state_str(s.state), + "version": s.version.clone().unwrap_or_default(), + "uptime_seconds": s.uptime_seconds, + "error": s.error_message.clone().unwrap_or_default(), + }) + }) + .collect(); + + Ok(serde_json::to_string_pretty(&json!({ + "overall_health": system_health_str(overall.overall_health), + "healthy": overall.healthy_services, + "total": overall.total_services, + "uptime_seconds": overall.system_uptime_seconds, + "services": services, + }))?) + } + + async fn tool_service_status(&self, args: &Value) -> Result { + let service = Self::arg_str(args, "service")?; + let resp = self + .client + .monitoring() + .get_system_status(crate::proto::monitoring::GetSystemStatusRequest { + service_names: vec![service.to_owned()], + }) + .await? + .into_inner(); + + let svc = resp + .service_statuses + .into_iter() + .find(|s| s.service_name == service) + .ok_or_else(|| anyhow::anyhow!("service '{service}' not found"))?; + + let deps: Vec = svc + .dependencies + .iter() + .map(|d| { + json!({ + "name": d.name, + "status": health_status_str(d.status), + "endpoint": d.endpoint.clone().unwrap_or_default(), + "response_time_ms": d.response_time_ms.unwrap_or_default(), + }) + }) + .collect(); + + Ok(serde_json::to_string_pretty(&json!({ + "name": svc.service_name, + "health": service_health_str(svc.health), + "state": service_state_str(svc.state), + "version": svc.version.unwrap_or_default(), + "uptime_seconds": svc.uptime_seconds, + "error": svc.error_message.unwrap_or_default(), + "metadata": svc.metadata, + "dependencies": deps, + }))?) + } + + async fn tool_service_health(&self) -> Result { + let resp = self + .client + .monitoring() + .get_health_check(crate::proto::monitoring::GetHealthCheckRequest { + service_name: None, + }) + .await? + .into_inner(); + + let checks: Vec = resp + .health_checks + .iter() + .map(|c| { + json!({ + "name": c.check_name, + "status": health_status_str(c.status), + "message": c.message.clone().unwrap_or_default(), + "response_time_ms": c.response_time_ms.unwrap_or_default(), + }) + }) + .collect(); + + Ok(serde_json::to_string_pretty(&json!({ + "overall": health_status_str(resp.health_status), + "checks": checks, + }))?) + } + + // ══════════════════════════════════════════════════════════════════ + // Training tools + // ══════════════════════════════════════════════════════════════════ + + async fn tool_train_start(&self, args: &Value) -> Result { + let model = Self::arg_str(args, "model")?; + let symbol = Self::arg_str_opt(args, "symbol"); + + let data_source = symbol.map(|s| crate::proto::ml_training::DataSource { + source: Some(crate::proto::ml_training::data_source::Source::FilePath( + s.to_owned(), + )), + start_time: 0, + end_time: 0, + }); + + let resp = self + .client + .ml_training() + .start_training(crate::proto::ml_training::StartTrainingRequest { + model_type: model.to_uppercase(), + data_source, + hyperparameters: None, + use_gpu: true, + description: String::new(), + tags: Default::default(), + mode: crate::proto::ml_training::TrainingMode::Full as i32, + resume_checkpoint_path: String::new(), + max_epochs: 0, + }) + .await? + .into_inner(); + + Ok(serde_json::to_string_pretty(&json!({ + "job_id": resp.job_id, + "status": training_status_str(resp.status), + "message": resp.message, + }))?) + } + + async fn tool_train_stop(&self, args: &Value) -> Result { + let job_id = Self::arg_str(args, "job_id")?; + let resp = self + .client + .ml_training() + .stop_training(crate::proto::ml_training::StopTrainingRequest { + job_id: job_id.to_owned(), + reason: String::new(), + }) + .await? + .into_inner(); + + Ok(serde_json::to_string_pretty(&json!({ + "success": resp.success, + "message": resp.message, + }))?) + } + + async fn tool_train_status(&self, args: &Value) -> Result { + let job_id = Self::arg_str(args, "job_id")?; + let resp = self + .client + .ml_training() + .get_training_job_details( + crate::proto::ml_training::GetTrainingJobDetailsRequest { + job_id: job_id.to_owned(), + }, + ) + .await? + .into_inner(); + + let details = resp.job_details.unwrap_or_default(); + let fm = details.final_financial_metrics.as_ref(); + + Ok(serde_json::to_string_pretty(&json!({ + "job_id": details.job_id, + "model_type": details.model_type, + "status": training_status_str(details.status), + "description": details.description, + "created_at": details.created_at, + "started_at": details.started_at, + "completed_at": details.completed_at, + "error": details.error_message, + "artifact_path": details.model_artifact_path, + "financial_metrics": fm.map(|m| json!({ + "sharpe_ratio": m.sharpe_ratio, + "max_drawdown": m.max_drawdown, + "hit_rate": m.hit_rate, + "simulated_return": m.simulated_return, + })), + }))?) + } + + async fn tool_train_list(&self) -> Result { + let resp = self + .client + .ml_training() + .list_training_jobs(crate::proto::ml_training::ListTrainingJobsRequest { + page: 0, + page_size: 50, + status_filter: 0, + model_type_filter: String::new(), + start_time: 0, + end_time: 0, + }) + .await? + .into_inner(); + + let jobs: Vec = resp + .jobs + .iter() + .map(|j| { + json!({ + "job_id": j.job_id, + "model_type": j.model_type, + "status": training_status_str(j.status), + "description": j.description, + "final_loss": j.final_loss, + "best_validation_score": j.best_validation_score, + "created_at": j.created_at, + }) + }) + .collect(); + + Ok(serde_json::to_string_pretty(&json!({ + "total": resp.total_count, + "jobs": jobs, + }))?) + } + + // ══════════════════════════════════════════════════════════════════ + // Trading tools + // ══════════════════════════════════════════════════════════════════ + + async fn tool_trade_positions(&self) -> Result { + let resp = self + .client + .trading() + .get_positions(crate::proto::trading::GetPositionsRequest { + account_id: None, + symbol: None, + }) + .await? + .into_inner(); + + let positions: Vec = resp + .positions + .iter() + .map(|p| { + json!({ + "symbol": p.symbol, + "quantity": p.quantity, + "avg_price": p.average_price, + "market_value": p.market_value, + "unrealized_pnl": p.unrealized_pnl, + "realized_pnl": p.realized_pnl, + }) + }) + .collect(); + + Ok(serde_json::to_string_pretty(&json!({ + "positions": positions, + }))?) + } + + async fn tool_trade_orders(&self, args: &Value) -> Result { + let order_id = Self::arg_str_opt(args, "order_id"); + + if let Some(oid) = order_id { + let resp = self + .client + .trading() + .get_order_status(crate::proto::trading::GetOrderStatusRequest { + order_id: oid.to_owned(), + }) + .await? + .into_inner(); + + let order = resp + .order + .ok_or_else(|| anyhow::anyhow!("order {oid} not found"))?; + + Ok(serde_json::to_string_pretty(&json!({ + "order_id": order.order_id, + "symbol": order.symbol, + "side": side_name(order.side), + "quantity": order.quantity, + "filled_quantity": order.filled_quantity, + "order_type": order_type_name(order.order_type), + "price": order.price, + "status": order_status_name(order.status), + }))?) + } else { + // No order_id: return a hint + Ok(serde_json::to_string_pretty(&json!({ + "message": "Provide an order_id argument to look up a specific order. Use fxt_trade_positions for current positions.", + }))?) + } + } + + async fn tool_trade_account(&self) -> Result { + let resp = self + .client + .broker_gateway() + .get_account_state(crate::proto::broker_gateway::GetAccountStateRequest { + account_id: String::new(), + }) + .await? + .into_inner(); + + Ok(serde_json::to_string_pretty(&json!({ + "account_id": resp.account_id, + "cash_balance": resp.cash_balance, + "equity": resp.equity, + "margin_used": resp.margin_used, + "margin_available": resp.margin_available, + "buying_power": resp.buying_power, + "unrealized_pnl": resp.unrealized_pnl, + "realized_pnl": resp.realized_pnl, + }))?) + } + + async fn tool_trade_submit(&self, args: &Value) -> Result { + let symbol = Self::arg_str(args, "symbol")?; + let side = Self::arg_str(args, "side")?; + let quantity = Self::arg_f64_opt(args, "quantity") + .ok_or_else(|| anyhow::anyhow!("missing required argument: quantity"))?; + let order_type_str = Self::arg_str(args, "order_type")?; + let price = Self::arg_f64_opt(args, "price"); + + let side_val = match side.to_lowercase().as_str() { + "buy" | "long" => crate::proto::trading::OrderSide::Buy as i32, + "sell" | "short" => crate::proto::trading::OrderSide::Sell as i32, + _ => return Err(anyhow::anyhow!("invalid side '{side}': expected 'buy' or 'sell'")), }; - Err(anyhow::anyhow!( - "{}", - json!({ - "error": "not_implemented", - "tool": tool_name, - "message": format!( - "This tool is not yet connected to the API Gateway (gRPC {grpc_method})" - ) + let order_type = match order_type_str.to_lowercase().as_str() { + "market" => crate::proto::trading::OrderType::Market as i32, + "limit" => crate::proto::trading::OrderType::Limit as i32, + "stop" => crate::proto::trading::OrderType::Stop as i32, + _ => crate::proto::trading::OrderType::Market as i32, + }; + + let resp = self + .client + .trading() + .submit_order(crate::proto::trading::SubmitOrderRequest { + symbol: symbol.to_owned(), + side: side_val, + quantity, + order_type, + price, + stop_price: None, + account_id: String::new(), + metadata: std::collections::HashMap::new(), }) - )) + .await? + .into_inner(); + + Ok(serde_json::to_string_pretty(&json!({ + "order_id": resp.order_id, + "status": order_status_name(resp.status), + "message": resp.message, + }))?) + } + + // ══════════════════════════════════════════════════════════════════ + // Model tools + // ══════════════════════════════════════════════════════════════════ + + async fn tool_model_list(&self) -> Result { + let resp = self + .client + .ml() + .get_available_models(crate::proto::ml::GetAvailableModelsRequest {}) + .await? + .into_inner(); + + let models: Vec = resp + .available_models + .iter() + .map(|m| { + json!({ + "name": m.model_name, + "model_type": m.model_type, + "description": m.description, + "symbols": m.supported_symbols, + }) + }) + .collect(); + + Ok(serde_json::to_string_pretty(&json!({ + "count": models.len(), + "models": models, + }))?) + } + + async fn tool_model_status(&self, args: &Value) -> Result { + let model = Self::arg_str(args, "model")?; + let resp = self + .client + .ml() + .get_model_status(crate::proto::ml::GetModelStatusRequest { + model_name: Some(model.to_owned()), + }) + .await? + .into_inner(); + + let s = resp + .model_statuses + .into_iter() + .find(|s| s.model_name == model) + .ok_or_else(|| anyhow::anyhow!("model '{model}' not found"))?; + + Ok(serde_json::to_string_pretty(&json!({ + "name": s.model_name, + "state": model_state_str(s.state), + "health": model_health_str(s.health), + "error": s.error_message.unwrap_or_default(), + "last_updated": s.last_updated, + "last_prediction": s.last_prediction, + "metadata": s.metadata, + }))?) + } + + async fn tool_model_predict(&self, args: &Value) -> Result { + let model = Self::arg_str(args, "model")?; + let symbol = Self::arg_str(args, "symbol")?; + let resp = self + .client + .ml() + .get_prediction(crate::proto::ml::GetPredictionRequest { + model_name: model.to_owned(), + symbol: symbol.to_owned(), + horizon_minutes: None, + features: Default::default(), + }) + .await? + .into_inner(); + + let pred = resp.prediction.unwrap_or_default(); + + Ok(serde_json::to_string_pretty(&json!({ + "model": pred.model_name, + "symbol": pred.symbol, + "prediction_type": prediction_type_str(pred.prediction_type), + "value": pred.value, + "confidence": resp.confidence, + "horizon_minutes": pred.horizon_minutes, + }))?) + } + + async fn tool_model_ensemble(&self, args: &Value) -> Result { + let symbol = Self::arg_str(args, "symbol")?; + let resp = self + .client + .ml() + .get_ensemble_vote(crate::proto::ml::GetEnsembleVoteRequest { + symbol: symbol.to_owned(), + horizon_minutes: None, + model_names: vec![], + }) + .await? + .into_inner(); + + let vote = resp.ensemble_vote.unwrap_or_default(); + let individual: Vec = resp + .individual_votes + .iter() + .map(|v| { + json!({ + "model": v.model_name, + "prediction": prediction_type_str(v.prediction), + "confidence": v.confidence, + "weight": v.weight, + }) + }) + .collect(); + + Ok(serde_json::to_string_pretty(&json!({ + "symbol": vote.symbol, + "consensus": prediction_type_str(vote.consensus_prediction), + "confidence": resp.overall_confidence, + "signal_strength": signal_strength_str(vote.signal_strength), + "votes_buy": vote.votes_buy, + "votes_sell": vote.votes_sell, + "votes_hold": vote.votes_hold, + "total_models": vote.total_models, + "individual_votes": individual, + }))?) + } + + // ══════════════════════════════════════════════════════════════════ + // Broker tools + // ══════════════════════════════════════════════════════════════════ + + async fn tool_broker_status(&self) -> Result { + let resp = self + .client + .broker_gateway() + .get_session_status(crate::proto::broker_gateway::GetSessionStatusRequest { + session_id: None, + }) + .await? + .into_inner(); + + Ok(serde_json::to_string_pretty(&json!({ + "session_id": resp.session_id, + "state": session_state_name(resp.state), + "sender_seq_num": resp.sender_seq_num, + "target_seq_num": resp.target_seq_num, + "heartbeat_rtt_ms": resp.heartbeat_rtt_ms, + "details": resp.details, + }))?) + } + + async fn tool_broker_connect(&self) -> Result { + let resp = self + .client + .broker_gateway() + .health_check(crate::proto::broker_gateway::HealthCheckRequest {}) + .await? + .into_inner(); + + Ok(serde_json::to_string_pretty(&json!({ + "healthy": resp.healthy, + "message": resp.message, + "details": resp.details, + }))?) + } + + // ══════════════════════════════════════════════════════════════════ + // Risk tools + // ══════════════════════════════════════════════════════════════════ + + async fn tool_risk_status(&self) -> Result { + let metrics_resp = self + .client + .risk() + .get_risk_metrics(crate::proto::risk::GetRiskMetricsRequest { + portfolio_id: None, + }) + .await? + .into_inner(); + + let cb_resp = self + .client + .risk() + .get_circuit_breaker_status( + crate::proto::risk::GetCircuitBreakerStatusRequest { symbol: None }, + ) + .await? + .into_inner(); + + let m = metrics_resp.metrics.unwrap_or_default(); + + let breakers: Vec = cb_resp + .circuit_breakers + .iter() + .map(|cb| { + json!({ + "name": cb.name, + "is_triggered": cb.is_triggered, + "trigger_reason": cb.trigger_reason.clone().unwrap_or_default(), + "breaker_type": breaker_type_name(cb.breaker_type), + }) + }) + .collect(); + + Ok(serde_json::to_string_pretty(&json!({ + "var_1d": m.portfolio_var_1d, + "var_5d": m.portfolio_var_5d, + "current_drawdown": m.current_drawdown, + "max_drawdown": m.max_drawdown, + "sharpe_ratio": m.sharpe_ratio, + "sortino_ratio": m.sortino_ratio, + "volatility": m.volatility, + "circuit_breakers": breakers, + }))?) + } + + async fn tool_risk_limits(&self) -> Result { + let metrics_resp = self + .client + .risk() + .get_risk_metrics(crate::proto::risk::GetRiskMetricsRequest { + portfolio_id: None, + }) + .await? + .into_inner(); + + let m = metrics_resp.metrics.unwrap_or_default(); + + let positions: Vec = m + .position_risks + .iter() + .map(|p| { + json!({ + "symbol": p.symbol, + "position_size": p.position_size, + "market_value": p.market_value, + "var_contribution": p.var_contribution, + "concentration_risk": p.concentration_risk, + "liquidity_risk": p.liquidity_risk, + }) + }) + .collect(); + + Ok(serde_json::to_string_pretty(&json!({ + "var_1d": m.portfolio_var_1d, + "var_5d": m.portfolio_var_5d, + "var_30d": m.portfolio_var_30d, + "positions": positions, + }))?) + } + + async fn tool_risk_drawdown(&self) -> Result { + let metrics_resp = self + .client + .risk() + .get_risk_metrics(crate::proto::risk::GetRiskMetricsRequest { + portfolio_id: None, + }) + .await? + .into_inner(); + + let m = metrics_resp.metrics.unwrap_or_default(); + + Ok(serde_json::to_string_pretty(&json!({ + "current_drawdown": m.current_drawdown, + "max_drawdown": m.max_drawdown, + "sharpe_ratio": m.sharpe_ratio, + "sortino_ratio": m.sortino_ratio, + "beta": m.beta, + "alpha": m.alpha, + }))?) + } + + async fn tool_risk_emergency(&self, args: &Value) -> Result { + let action = Self::arg_str(args, "action")?; + + if action == "resume" { + // Emergency resume is not a standard gRPC call -- return guidance. + return Ok(serde_json::to_string_pretty(&json!({ + "message": "Resume from emergency halt requires manual operator action via the trading agent service.", + }))?); + } + + let resp = self + .client + .risk() + .emergency_stop(crate::proto::risk::EmergencyStopRequest { + stop_type: crate::proto::risk::EmergencyStopType::AllTrading as i32, + reason: "Emergency halt via MCP tool".to_owned(), + symbol: None, + account_id: None, + }) + .await? + .into_inner(); + + Ok(serde_json::to_string_pretty(&json!({ + "success": resp.success, + "message": resp.message, + "affected_orders": resp.affected_orders, + }))?) + } + + // ══════════════════════════════════════════════════════════════════ + // Data tools + // ══════════════════════════════════════════════════════════════════ + + async fn tool_data_status(&self) -> Result { + let resp = self + .client + .data_acquisition() + .list_download_jobs( + crate::proto::data_acquisition::ListDownloadJobsRequest { + page: 0, + page_size: 50, + status_filter: 0, + start_time: 0, + end_time: 0, + }, + ) + .await? + .into_inner(); + + let jobs: Vec = resp + .jobs + .iter() + .map(|j| { + json!({ + "job_id": j.job_id, + "status": download_status_name(j.status), + "dataset": j.dataset, + "symbols": j.symbols, + "start_date": j.start_date, + "end_date": j.end_date, + "progress_pct": j.progress_percentage, + }) + }) + .collect(); + + Ok(serde_json::to_string_pretty(&json!({ + "total": resp.total_count, + "jobs": jobs, + }))?) + } + + async fn tool_data_feeds(&self) -> Result { + // The data acquisition service does not have a dedicated "list feeds" RPC. + // Return status from the service discovery / monitoring instead. + let resp = self + .client + .monitoring() + .get_system_status(crate::proto::monitoring::GetSystemStatusRequest { + service_names: vec!["data-acquisition".to_owned()], + }) + .await? + .into_inner(); + + let svc = resp + .service_statuses + .first() + .map(|s| { + json!({ + "service": s.service_name, + "health": service_health_str(s.health), + "state": service_state_str(s.state), + }) + }) + .unwrap_or(json!({"message": "data-acquisition service not found"})); + + Ok(serde_json::to_string_pretty(&svc)?) + } + + // ══════════════════════════════════════════════════════════════════ + // Cluster tools + // ══════════════════════════════════════════════════════════════════ + + async fn tool_cluster_status(&self) -> Result { + let resp = self + .client + .monitoring() + .get_system_status(crate::proto::monitoring::GetSystemStatusRequest { + service_names: vec![], + }) + .await? + .into_inner(); + + let overall = resp.overall_status.unwrap_or_default(); + let sys_metrics = overall.system_metrics.unwrap_or_default(); + + let services: Vec = resp + .service_statuses + .iter() + .map(|s| { + json!({ + "name": s.service_name, + "health": service_health_str(s.health), + "state": service_state_str(s.state), + "version": s.version.clone().unwrap_or_default(), + "uptime_seconds": s.uptime_seconds, + "error": s.error_message.clone().unwrap_or_default(), + }) + }) + .collect(); + + Ok(serde_json::to_string_pretty(&json!({ + "overall_health": system_health_str(overall.overall_health), + "healthy_services": overall.healthy_services, + "total_services": overall.total_services, + "critical_issues": overall.critical_issues, + "system_uptime_seconds": overall.system_uptime_seconds, + "cpu_usage_pct": sys_metrics.cpu_usage_percent, + "memory_usage_pct": sys_metrics.memory_usage_percent, + "disk_usage_pct": sys_metrics.disk_usage_percent, + "error_rate_pct": sys_metrics.error_rate_percent, + "services": services, + }))?) + } + + async fn tool_cluster_resources(&self) -> Result { + let resource_names = vec![ + "cpu.usage".to_owned(), + "memory.usage".to_owned(), + "disk.usage".to_owned(), + "gpu.utilization".to_owned(), + "gpu.memory.used".to_owned(), + "network.io".to_owned(), + ]; + + let resp = self + .client + .monitoring() + .get_metrics(crate::proto::monitoring::GetMetricsRequest { + metric_names: resource_names, + start_time: None, + end_time: None, + aggregation: None, + }) + .await? + .into_inner(); + + let metrics: Vec = resp + .metrics + .iter() + .map(|m| { + json!({ + "name": m.name, + "value": m.value, + "unit": m.unit, + }) + }) + .collect(); + + Ok(serde_json::to_string_pretty(&json!({ + "metrics": metrics, + }))?) + } + + // ══════════════════════════════════════════════════════════════════ + // Agent tools + // ══════════════════════════════════════════════════════════════════ + + async fn tool_agent_start(&self) -> Result { + // List all strategies and enable each one. + let list_resp = self + .client + .trading_agent() + .list_strategies(crate::proto::trading_agent::ListStrategiesRequest { + status_filter: None, + }) + .await? + .into_inner(); + + let mut results = Vec::new(); + for s in &list_resp.strategies { + let resp = self + .client + .trading_agent() + .update_strategy_status( + crate::proto::trading_agent::UpdateStrategyStatusRequest { + strategy_id: s.strategy_id.clone(), + new_status: crate::proto::trading_agent::StrategyStatus::Enabled as i32, + reason: None, + }, + ) + .await? + .into_inner(); + + results.push(json!({ + "strategy_id": s.strategy_id, + "success": resp.success, + "message": resp.message, + })); + } + + Ok(serde_json::to_string_pretty(&json!({ + "action": "start", + "strategies_updated": results.len(), + "results": results, + }))?) + } + + async fn tool_agent_stop(&self) -> Result { + // List all strategies and disable each one. + let list_resp = self + .client + .trading_agent() + .list_strategies(crate::proto::trading_agent::ListStrategiesRequest { + status_filter: None, + }) + .await? + .into_inner(); + + let mut results = Vec::new(); + for s in &list_resp.strategies { + let resp = self + .client + .trading_agent() + .update_strategy_status( + crate::proto::trading_agent::UpdateStrategyStatusRequest { + strategy_id: s.strategy_id.clone(), + new_status: crate::proto::trading_agent::StrategyStatus::Disabled as i32, + reason: None, + }, + ) + .await? + .into_inner(); + + results.push(json!({ + "strategy_id": s.strategy_id, + "success": resp.success, + "message": resp.message, + })); + } + + Ok(serde_json::to_string_pretty(&json!({ + "action": "stop", + "strategies_updated": results.len(), + "results": results, + }))?) + } + + async fn tool_agent_status(&self) -> Result { + let resp = self + .client + .trading_agent() + .get_agent_status(crate::proto::trading_agent::GetAgentStatusRequest { + include_performance: true, + include_positions: false, + }) + .await? + .into_inner(); + + let status = resp.status.unwrap_or_default(); + let perf = resp.performance.as_ref(); + + Ok(serde_json::to_string_pretty(&json!({ + "state": agent_state_name(status.state), + "active_strategies": status.active_strategies, + "selected_assets": status.selected_assets, + "portfolio_utilization": status.portfolio_utilization, + "current_universe_id": status.current_universe_id, + "performance": perf.map(|p| json!({ + "total_pnl": p.total_pnl, + "sharpe_ratio": p.sharpe_ratio, + "max_drawdown": p.max_drawdown, + "win_rate": p.win_rate, + "total_trades": p.total_trades, + "avg_trade_pnl": p.avg_trade_pnl, + })), + }))?) + } + + // ══════════════════════════════════════════════════════════════════ + // Config tools + // ══════════════════════════════════════════════════════════════════ + + async fn tool_config_get(&self, args: &Value) -> Result { + let key = Self::arg_str(args, "key")?; + let resp = self + .client + .config() + .get_configuration(crate::proto::config::GetConfigurationRequest { + category: None, + key: Some(key.to_owned()), + environment: None, + }) + .await? + .into_inner(); + + let settings: Vec = resp + .settings + .iter() + .map(|s| { + json!({ + "category": s.category, + "key": s.key, + "value": if s.sensitive { "********" } else { &s.value }, + "data_type": config_data_type_name(s.data_type), + "hot_reload": s.hot_reload, + "description": s.description, + }) + }) + .collect(); + + Ok(serde_json::to_string_pretty(&json!({ + "settings": settings, + }))?) + } + + async fn tool_config_set(&self, args: &Value) -> Result { + let key = Self::arg_str(args, "key")?; + let value = Self::arg_str(args, "value")?; + let resp = self + .client + .config() + .update_configuration(crate::proto::config::UpdateConfigurationRequest { + category: "default".to_owned(), + key: key.to_owned(), + value: value.to_owned(), + changed_by: "fxt-mcp".to_owned(), + change_reason: None, + environment: None, + }) + .await? + .into_inner(); + + Ok(serde_json::to_string_pretty(&json!({ + "success": resp.success, + "message": resp.message, + }))?) + } + + // ══════════════════════════════════════════════════════════════════ + // Tune tools + // ══════════════════════════════════════════════════════════════════ + + async fn tool_tune_start(&self, args: &Value) -> Result { + let model = Self::arg_str(args, "model")?; + let resp = self + .client + .ml_training() + .start_tuning_job(crate::proto::ml_training::StartTuningJobRequest { + model_type: model.to_uppercase(), + num_trials: 50, + config_path: String::new(), + data_source: None, + use_gpu: true, + description: String::new(), + tags: Default::default(), + }) + .await? + .into_inner(); + + Ok(serde_json::to_string_pretty(&json!({ + "job_id": resp.job_id, + "status": tuning_status_str(resp.status), + "message": resp.message, + }))?) + } + + async fn tool_tune_status(&self, args: &Value) -> Result { + let job_id = Self::arg_str_opt(args, "job_id") + .ok_or_else(|| anyhow::anyhow!("missing required argument: job_id (provide the tuning job ID)"))?; + + let resp = self + .client + .ml_training() + .get_tuning_job_status(crate::proto::ml_training::GetTuningJobStatusRequest { + job_id: job_id.to_owned(), + }) + .await? + .into_inner(); + + let best_params: Vec = resp + .best_params + .iter() + .map(|(k, v)| json!({"name": k, "value": v})) + .collect(); + + let best_metrics: Vec = resp + .best_metrics + .iter() + .map(|(k, v)| json!({"name": k, "value": v})) + .collect(); + + let trials: Vec = resp + .trial_history + .iter() + .map(|t| { + json!({ + "trial": t.trial_number, + "objective": t.objective_value, + "state": trial_state_str(t.state), + "started_at": t.started_at, + "completed_at": t.completed_at, + }) + }) + .collect(); + + Ok(serde_json::to_string_pretty(&json!({ + "job_id": resp.job_id, + "status": tuning_status_str(resp.status), + "current_trial": resp.current_trial, + "total_trials": resp.total_trials, + "best_params": best_params, + "best_metrics": best_metrics, + "message": resp.message, + "started_at": resp.started_at, + "updated_at": resp.updated_at, + "trials": trials, + }))?) + } +} + +// ══════════════════════════════════════════════════════════════════════ +// Enum display helpers (match the CLI command patterns) +// ══════════════════════════════════════════════════════════════════════ + +fn service_health_str(h: i32) -> &'static str { + use crate::proto::monitoring::ServiceHealth; + match ServiceHealth::try_from(h) { + Ok(ServiceHealth::Healthy) => "healthy", + Ok(ServiceHealth::Degraded) => "degraded", + Ok(ServiceHealth::Unhealthy) => "unhealthy", + Ok(ServiceHealth::Critical) => "critical", + _ => "unknown", + } +} + +fn service_state_str(s: i32) -> &'static str { + use crate::proto::monitoring::ServiceState; + match ServiceState::try_from(s) { + Ok(ServiceState::Starting) => "starting", + Ok(ServiceState::Running) => "running", + Ok(ServiceState::Stopping) => "stopping", + Ok(ServiceState::Stopped) => "stopped", + Ok(ServiceState::Error) => "error", + _ => "unknown", + } +} + +fn system_health_str(h: i32) -> &'static str { + use crate::proto::monitoring::SystemHealth; + match SystemHealth::try_from(h) { + Ok(SystemHealth::Healthy) => "healthy", + Ok(SystemHealth::Degraded) => "degraded", + Ok(SystemHealth::Unhealthy) => "unhealthy", + Ok(SystemHealth::Critical) => "critical", + _ => "unknown", + } +} + +fn health_status_str(h: i32) -> &'static str { + use crate::proto::monitoring::HealthStatus; + match HealthStatus::try_from(h) { + Ok(HealthStatus::Healthy) => "healthy", + Ok(HealthStatus::Degraded) => "degraded", + Ok(HealthStatus::Unhealthy) => "unhealthy", + Ok(HealthStatus::Critical) => "critical", + _ => "unknown", + } +} + +fn training_status_str(s: i32) -> &'static str { + use crate::proto::ml_training::TrainingStatus; + match TrainingStatus::try_from(s) { + Ok(TrainingStatus::Pending) => "pending", + Ok(TrainingStatus::Running) => "running", + Ok(TrainingStatus::Completed) => "completed", + Ok(TrainingStatus::Failed) => "failed", + Ok(TrainingStatus::Stopped) => "stopped", + Ok(TrainingStatus::Paused) => "paused", + _ => "unknown", + } +} + +fn tuning_status_str(s: i32) -> &'static str { + use crate::proto::ml_training::TuningJobStatus; + match TuningJobStatus::try_from(s) { + Ok(TuningJobStatus::TuningPending) => "pending", + Ok(TuningJobStatus::TuningRunning) => "running", + Ok(TuningJobStatus::TuningCompleted) => "completed", + Ok(TuningJobStatus::TuningFailed) => "failed", + Ok(TuningJobStatus::TuningStopped) => "stopped", + _ => "unknown", + } +} + +fn trial_state_str(s: i32) -> &'static str { + use crate::proto::ml_training::TrialState; + match TrialState::try_from(s) { + Ok(TrialState::TrialRunning) => "running", + Ok(TrialState::TrialComplete) => "complete", + Ok(TrialState::TrialPruned) => "pruned", + Ok(TrialState::TrialFailed) => "failed", + _ => "unknown", + } +} + +fn side_name(v: i32) -> &'static str { + use crate::proto::trading::OrderSide; + match OrderSide::try_from(v) { + Ok(OrderSide::Buy) => "BUY", + Ok(OrderSide::Sell) => "SELL", + _ => "UNKNOWN", + } +} + +fn order_type_name(v: i32) -> &'static str { + use crate::proto::trading::OrderType; + match OrderType::try_from(v) { + Ok(OrderType::Market) => "MARKET", + Ok(OrderType::Limit) => "LIMIT", + Ok(OrderType::Stop) => "STOP", + Ok(OrderType::StopLimit) => "STOP_LIMIT", + _ => "UNKNOWN", + } +} + +fn order_status_name(v: i32) -> &'static str { + use crate::proto::trading::OrderStatus; + match OrderStatus::try_from(v) { + Ok(OrderStatus::Pending) => "PENDING", + Ok(OrderStatus::Submitted) => "SUBMITTED", + Ok(OrderStatus::PartiallyFilled) => "PARTIALLY_FILLED", + Ok(OrderStatus::Filled) => "FILLED", + Ok(OrderStatus::Cancelled) => "CANCELLED", + Ok(OrderStatus::Rejected) => "REJECTED", + _ => "UNKNOWN", + } +} + +fn model_state_str(s: i32) -> &'static str { + use crate::proto::ml::ModelState; + match ModelState::try_from(s) { + Ok(ModelState::Loading) => "loading", + Ok(ModelState::Ready) => "ready", + Ok(ModelState::Predicting) => "predicting", + Ok(ModelState::Training) => "training", + Ok(ModelState::Error) => "error", + Ok(ModelState::Offline) => "offline", + _ => "unknown", + } +} + +fn model_health_str(h: i32) -> &'static str { + use crate::proto::ml::ModelHealth; + match ModelHealth::try_from(h) { + Ok(ModelHealth::Healthy) => "healthy", + Ok(ModelHealth::Degraded) => "degraded", + Ok(ModelHealth::Unhealthy) => "unhealthy", + Ok(ModelHealth::Critical) => "critical", + _ => "unknown", + } +} + +fn prediction_type_str(p: i32) -> &'static str { + use crate::proto::ml::PredictionType; + match PredictionType::try_from(p) { + Ok(PredictionType::Buy) => "BUY", + Ok(PredictionType::Sell) => "SELL", + Ok(PredictionType::Hold) => "HOLD", + Ok(PredictionType::PriceUp) => "PRICE_UP", + Ok(PredictionType::PriceDown) => "PRICE_DOWN", + Ok(PredictionType::VolatilityHigh) => "VOL_HIGH", + Ok(PredictionType::VolatilityLow) => "VOL_LOW", + _ => "UNKNOWN", + } +} + +fn signal_strength_str(s: i32) -> &'static str { + use crate::proto::ml::SignalStrength; + match SignalStrength::try_from(s) { + Ok(SignalStrength::VeryWeak) => "very_weak", + Ok(SignalStrength::Weak) => "weak", + Ok(SignalStrength::Moderate) => "moderate", + Ok(SignalStrength::Strong) => "strong", + Ok(SignalStrength::VeryStrong) => "very_strong", + _ => "unknown", + } +} + +fn session_state_name(v: i32) -> &'static str { + use crate::proto::broker_gateway::SessionState; + match SessionState::try_from(v) { + Ok(SessionState::Disconnected) => "DISCONNECTED", + Ok(SessionState::Connected) => "CONNECTED", + Ok(SessionState::LoggingIn) => "LOGGING_IN", + Ok(SessionState::Active) => "ACTIVE", + Ok(SessionState::LoggingOut) => "LOGGING_OUT", + _ => "UNKNOWN", + } +} + +fn breaker_type_name(value: i32) -> &'static str { + use crate::proto::risk::CircuitBreakerType; + match CircuitBreakerType::try_from(value) { + Ok(CircuitBreakerType::PortfolioLoss) => "portfolio-loss", + Ok(CircuitBreakerType::SymbolVolatility) => "symbol-volatility", + Ok(CircuitBreakerType::PositionSize) => "position-size", + Ok(CircuitBreakerType::Drawdown) => "drawdown", + _ => "unknown", + } +} + +fn agent_state_name(v: i32) -> &'static str { + use crate::proto::trading_agent::AgentState; + match AgentState::try_from(v) { + Ok(AgentState::Initializing) => "INITIALIZING", + Ok(AgentState::Active) => "ACTIVE", + Ok(AgentState::Paused) => "PAUSED", + Ok(AgentState::Error) => "ERROR", + Ok(AgentState::Shutdown) => "SHUTDOWN", + _ => "UNKNOWN", + } +} + +fn config_data_type_name(value: i32) -> &'static str { + use crate::proto::config::ConfigDataType; + match ConfigDataType::try_from(value) { + Ok(ConfigDataType::String) => "string", + Ok(ConfigDataType::Number) => "number", + Ok(ConfigDataType::Boolean) => "boolean", + Ok(ConfigDataType::Json) => "json", + Ok(ConfigDataType::Encrypted) => "encrypted", + _ => "unspecified", + } +} + +fn download_status_name(value: i32) -> &'static str { + use crate::proto::data_acquisition::DownloadStatus; + match DownloadStatus::try_from(value) { + Ok(DownloadStatus::Pending) => "PENDING", + Ok(DownloadStatus::Downloading) => "DOWNLOADING", + Ok(DownloadStatus::Validating) => "VALIDATING", + Ok(DownloadStatus::Uploading) => "UPLOADING", + Ok(DownloadStatus::Completed) => "COMPLETED", + Ok(DownloadStatus::Failed) => "FAILED", + Ok(DownloadStatus::Cancelled) => "CANCELLED", + _ => "UNKNOWN", } } @@ -294,8 +1616,22 @@ async fn write_response( mod tests { use super::*; - fn make_server() -> McpServer { - McpServer::new("http://localhost:9090".into()) + /// Create an MCP server with a lazily-connected gRPC client (no real server). + /// Tests only exercise protocol handling; actual gRPC calls will return + /// transport errors which are surfaced as `isError` tool results. + async fn make_server() -> McpServer { + // Ensure the token storage directory exists so FileTokenStorage::new() + // does not fail during tests. + let config_dir = dirs::config_dir() + .unwrap_or_else(|| std::path::PathBuf::from("/tmp")) + .join("foxhunt-tli") + .join("tokens"); + let _ = std::fs::create_dir_all(&config_dir); + + let client = FoxhuntClient::connect("http://localhost:9090") + .await + .unwrap(); + McpServer::new("http://localhost:9090".into(), client) } fn make_request(method: &str, id: Option, params: Value) -> JsonRpcRequest { @@ -309,7 +1645,7 @@ mod tests { #[tokio::test] async fn initialize_returns_capabilities() { - let server = make_server(); + let server = make_server().await; let req = make_request("initialize", Some(json!(1)), json!({})); let resp = server.handle_request(&req).await.unwrap(); let result = resp.result.unwrap(); @@ -320,7 +1656,7 @@ mod tests { #[tokio::test] async fn notification_returns_none() { - let server = make_server(); + let server = make_server().await; let req = make_request("notifications/initialized", None, json!({})); let resp = server.handle_request(&req).await; assert!(resp.is_none()); @@ -328,7 +1664,7 @@ mod tests { #[tokio::test] async fn tools_list_returns_all_tools() { - let server = make_server(); + let server = make_server().await; let req = make_request("tools/list", Some(json!(2)), json!({})); let resp = server.handle_request(&req).await.unwrap(); let result = resp.result.unwrap(); @@ -343,27 +1679,9 @@ mod tests { } } - #[tokio::test] - async fn tools_call_known_tool_returns_not_implemented() { - let server = make_server(); - let req = make_request( - "tools/call", - Some(json!(3)), - json!({"name": "fxt_service_list", "arguments": {}}), - ); - let resp = server.handle_request(&req).await.unwrap(); - // Tool-level error: protocol success with isError flag. - assert!(resp.error.is_none()); - let result = resp.result.unwrap(); - assert_eq!(result["isError"], true); - let text = result["content"][0]["text"].as_str().unwrap(); - assert!(text.contains("not_implemented")); - assert!(text.contains("fxt_service_list")); - } - #[tokio::test] async fn tools_call_unknown_tool() { - let server = make_server(); + let server = make_server().await; let req = make_request( "tools/call", Some(json!(4)), @@ -376,7 +1694,7 @@ mod tests { #[tokio::test] async fn tools_call_missing_name() { - let server = make_server(); + let server = make_server().await; let req = make_request("tools/call", Some(json!(5)), json!({"arguments": {}})); let resp = server.handle_request(&req).await.unwrap(); assert!(resp.error.is_some()); @@ -385,61 +1703,36 @@ mod tests { #[tokio::test] async fn unknown_method_returns_error() { - let server = make_server(); + let server = make_server().await; let req = make_request("bogus/method", Some(json!(6)), json!({})); let resp = server.handle_request(&req).await.unwrap(); assert!(resp.error.is_some()); assert_eq!(resp.error.unwrap().code, METHOD_NOT_FOUND); } - #[tokio::test] - async fn tools_call_with_arguments_returns_not_implemented() { - let server = make_server(); + #[tokio::test(flavor = "multi_thread")] + async fn tools_call_known_tool_returns_grpc_error() { + // With no real server, the gRPC call should fail with a transport error, + // which is returned as an isError tool result (not a protocol error). + // Requires multi_thread because the auth interceptor uses spawn_blocking. + let server = make_server().await; let req = make_request( "tools/call", - Some(json!(7)), - json!({ - "name": "fxt_train_start", - "arguments": {"model": "dqn", "symbol": "NQ.FUT"} - }), + Some(json!(3)), + json!({"name": "fxt_service_list", "arguments": {}}), ); let resp = server.handle_request(&req).await.unwrap(); + // Protocol-level success (no error field), but tool returned isError. + assert!(resp.error.is_none()); let result = resp.result.unwrap(); assert_eq!(result["isError"], true); let text = result["content"][0]["text"].as_str().unwrap(); - assert!(text.contains("not_implemented")); - assert!(text.contains("fxt_train_start")); - assert!(text.contains("MLTraining/StartTraining")); - } - - #[tokio::test] - async fn tools_call_trade_submit_returns_not_implemented() { - let server = make_server(); - let req = make_request( - "tools/call", - Some(json!(8)), - json!({ - "name": "fxt_trade_submit", - "arguments": { - "symbol": "ES.FUT", - "side": "buy", - "quantity": 1, - "order_type": "market" - } - }), - ); - let resp = server.handle_request(&req).await.unwrap(); - let result = resp.result.unwrap(); - assert_eq!(result["isError"], true); - let text = result["content"][0]["text"].as_str().unwrap(); - assert!(text.contains("not_implemented")); - assert!(text.contains("fxt_trade_submit")); - assert!(text.contains("TradingService/SubmitOrder")); + assert!(text.contains("error:")); } #[tokio::test] async fn initialize_response_has_version() { - let server = make_server(); + let server = make_server().await; let req = make_request("initialize", Some(json!(9)), json!({})); let resp = server.handle_request(&req).await.unwrap(); let result = resp.result.unwrap(); @@ -447,26 +1740,6 @@ mod tests { assert!(!version.is_empty()); } - #[tokio::test] - async fn tool_execution_risk_emergency_returns_not_implemented() { - let server = make_server(); - let req = make_request( - "tools/call", - Some(json!(10)), - json!({ - "name": "fxt_risk_emergency", - "arguments": {"action": "halt"} - }), - ); - let resp = server.handle_request(&req).await.unwrap(); - let result = resp.result.unwrap(); - assert_eq!(result["isError"], true); - let text = result["content"][0]["text"].as_str().unwrap(); - assert!(text.contains("not_implemented")); - assert!(text.contains("fxt_risk_emergency")); - assert!(text.contains("RiskService/EmergencyAction")); - } - #[tokio::test] async fn malformed_json_returns_parse_error() { // We cannot drive the full `run()` loop easily, so test the parse @@ -480,7 +1753,7 @@ mod tests { #[tokio::test] async fn wrong_jsonrpc_version_returns_invalid_request() { - let server = make_server(); + let server = make_server().await; let req = JsonRpcRequest { jsonrpc: "1.0".into(), id: Some(json!(99)),