futu_mcp/
tools.rs

1//! MCP 工具定义(#[tool] 薄封装,业务逻辑在 handlers/)
2
3use futu_auth::CheckCtx;
4use rmcp::{
5    handler::server::wrapper::Parameters, schemars, service::RequestContext, tool, tool_router,
6    RoleServer,
7};
8use serde::{Deserialize, Serialize};
9
10use crate::guard;
11use crate::handlers;
12use crate::state::ServerState;
13
14// ========== 请求类型 ==========
15
16#[derive(Debug, Deserialize, schemars::JsonSchema)]
17pub struct SymbolReq {
18    #[schemars(description = "Security symbol in MARKET.CODE format, e.g. HK.00700, US.AAPL")]
19    pub symbol: String,
20}
21
22#[derive(Debug, Deserialize, schemars::JsonSchema)]
23pub struct SymbolListReq {
24    #[schemars(description = "List of security symbols, each MARKET.CODE format")]
25    pub symbols: Vec<String>,
26}
27
28#[derive(Debug, Deserialize, schemars::JsonSchema)]
29pub struct KLineReq {
30    #[schemars(description = "Security symbol (MARKET.CODE)")]
31    pub symbol: String,
32    #[schemars(
33        description = "K-line type: day|week|month|quarter|year|1min|3min|5min|15min|30min|60min"
34    )]
35    #[serde(default = "default_kl_type")]
36    pub kl_type: String,
37    #[schemars(description = "Number of candles to return (default 100)")]
38    pub count: Option<i32>,
39    #[schemars(description = "Start date yyyy-MM-dd (optional; default computed from count)")]
40    pub begin: Option<String>,
41    #[schemars(description = "End date yyyy-MM-dd (optional; default today)")]
42    pub end: Option<String>,
43}
44
45fn default_kl_type() -> String {
46    "day".to_string()
47}
48
49#[derive(Debug, Deserialize, schemars::JsonSchema)]
50pub struct OrderBookReq {
51    pub symbol: String,
52    #[schemars(description = "Order book depth, 1-10 (default 10)")]
53    #[serde(default = "default_depth")]
54    pub depth: i32,
55}
56
57fn default_depth() -> i32 {
58    10
59}
60
61#[derive(Debug, Deserialize, schemars::JsonSchema)]
62pub struct TickerReq {
63    pub symbol: String,
64    #[schemars(description = "Number of ticks to fetch (default 100, max 1000)")]
65    #[serde(default = "default_ticker_count")]
66    pub count: i32,
67}
68
69fn default_ticker_count() -> i32 {
70    100
71}
72
73#[derive(Debug, Deserialize, schemars::JsonSchema)]
74pub struct PlateListReq {
75    #[schemars(description = "Market: HK|HK_FUTURE|US|SH|SZ")]
76    pub market: String,
77    #[schemars(description = "Plate set: all|industry|region|concept (default all)")]
78    #[serde(default = "default_plate_set")]
79    pub plate_set: String,
80}
81
82fn default_plate_set() -> String {
83    "all".to_string()
84}
85
86#[derive(Debug, Deserialize, schemars::JsonSchema)]
87pub struct PlateStocksReq {
88    #[schemars(description = "Plate symbol, MARKET.CODE format (e.g. HK.LIST1001)")]
89    pub plate: String,
90}
91
92#[derive(Debug, Deserialize, schemars::JsonSchema)]
93pub struct TrdAccReq {
94    #[schemars(description = "Trade market: HK|US|CN|HKCC")]
95    pub market: String,
96    #[schemars(description = "Trading account ID (u64)")]
97    pub acc_id: u64,
98    #[schemars(description = "Trade environment: real|simulate (default real)")]
99    #[serde(default = "default_env")]
100    pub env: String,
101}
102
103fn default_env() -> String {
104    "real".to_string()
105}
106
107// ----- 交易写入请求 -----
108
109#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
110pub struct PlaceOrderReq {
111    #[schemars(description = "Trade market: HK|US|CN|HKCC")]
112    pub market: String,
113    #[schemars(description = "Trading account ID (u64)")]
114    pub acc_id: u64,
115    #[schemars(description = "Trade environment: real|simulate. Defaults to simulate for safety.")]
116    #[serde(default = "default_env_simulate")]
117    pub env: String,
118    #[schemars(description = "Order side: BUY|SELL|SELL_SHORT|BUY_BACK")]
119    pub side: String,
120    #[schemars(
121        description = "Order type: NORMAL (limit) | MARKET | ABSOLUTE_LIMIT | AUCTION | AUCTION_LIMIT | SPECIAL_LIMIT"
122    )]
123    #[serde(default = "default_order_type")]
124    pub order_type: String,
125    #[schemars(description = "Security code WITHOUT market prefix, e.g. 00700 / AAPL / 600519")]
126    pub code: String,
127    #[schemars(description = "Order quantity (shares / contracts)")]
128    pub qty: f64,
129    #[schemars(description = "Limit price (required for NORMAL; optional for MARKET)")]
130    pub price: Option<f64>,
131    #[schemars(
132        description = "Optional per-call API key override (plaintext). When set, this key is used for scope/limits/audit instead of the process-wide FUTU_MCP_API_KEY. Useful for multi-tenant scenarios where different calls should be billed / scoped to different keys."
133    )]
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub api_key: Option<String>,
136}
137
138fn default_env_simulate() -> String {
139    "simulate".to_string()
140}
141
142fn default_order_type() -> String {
143    "NORMAL".to_string()
144}
145
146#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
147pub struct ModifyOrderReq {
148    #[schemars(description = "Trade market: HK|US|CN|HKCC")]
149    pub market: String,
150    #[schemars(description = "Trading account ID (u64)")]
151    pub acc_id: u64,
152    #[schemars(description = "Trade environment: real|simulate (default simulate)")]
153    #[serde(default = "default_env_simulate")]
154    pub env: String,
155    #[schemars(description = "Order ID to modify")]
156    pub order_id: u64,
157    #[schemars(
158        description = "Modify op: NORMAL (change qty/price) | CANCEL | DISABLE | ENABLE | DELETE"
159    )]
160    #[serde(default = "default_modify_op")]
161    pub op: String,
162    #[schemars(description = "New quantity (for NORMAL op)")]
163    pub qty: Option<f64>,
164    #[schemars(description = "New price (for NORMAL op)")]
165    pub price: Option<f64>,
166    #[schemars(description = "Optional per-call API key override. See PlaceOrderReq.api_key.")]
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub api_key: Option<String>,
169}
170
171fn default_modify_op() -> String {
172    "NORMAL".to_string()
173}
174
175#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
176pub struct CancelOrderReq {
177    #[schemars(description = "Trade market: HK|US|CN|HKCC")]
178    pub market: String,
179    #[schemars(description = "Trading account ID (u64)")]
180    pub acc_id: u64,
181    #[schemars(description = "Trade environment: real|simulate (default simulate)")]
182    #[serde(default = "default_env_simulate")]
183    pub env: String,
184    #[schemars(description = "Order ID to cancel")]
185    pub order_id: u64,
186    #[schemars(description = "Optional per-call API key override. See PlaceOrderReq.api_key.")]
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub api_key: Option<String>,
189}
190
191// ========== Server ==========
192
193#[derive(Clone)]
194pub struct FutuServer {
195    pub state: ServerState,
196}
197
198impl FutuServer {
199    pub fn new(state: ServerState) -> Self {
200        Self { state }
201    }
202
203    fn err(msg: impl std::fmt::Display) -> String {
204        serde_json::json!({ "error": msg.to_string() }).to_string()
205    }
206
207    fn wrap<E: std::fmt::Display>(res: std::result::Result<String, E>) -> String {
208        match res {
209            Ok(s) => s,
210            Err(e) => Self::err(e),
211        }
212    }
213
214    async fn client_or_err(
215        &self,
216    ) -> std::result::Result<std::sync::Arc<futu_net::client::FutuClient>, String> {
217        self.state
218            .client()
219            .await
220            .map_err(|e| Self::err(format!("gateway connect failed: {e}")))
221    }
222
223    /// 只读工具的 scope 守卫(基于中央注册表 `guard::scope_for_tool`);
224    /// 若 `tool` 未登记则 fail-closed 拒绝。返回 Some(错误 JSON) 表示被拒绝。
225    fn require_tool_scope(&self, tool: &'static str) -> Option<String> {
226        guard::require_tool_scope(&self.state, tool).into_err_json()
227    }
228
229    /// 交易写守卫;ctx=Some 时同时做限额检查;override_key=Some 时优先用该 plaintext
230    fn require_trading(
231        &self,
232        tool: &'static str,
233        env: &str,
234        ctx: Option<CheckCtx>,
235        override_key: Option<&str>,
236    ) -> Option<String> {
237        guard::require_trading(&self.state, tool, env, ctx, override_key).into_err_json()
238    }
239
240    /// 当前请求的 key_id(legacy 模式返回 None)。
241    /// `override_key`:per-call `api_key` 参数;若命中一个有效 key,返回其 id;
242    /// 若为 None 或未命中,回落到 startup 捕获的 `state.authed_key.id`。
243    fn current_key_id(&self, override_key: Option<&str>) -> Option<String> {
244        if let Some(pt) = override_key.filter(|p| !p.is_empty()) {
245            if let Some(rec) = self.state.key_store.verify(pt) {
246                return Some(rec.id.clone());
247            }
248        }
249        self.state.authed_key.as_ref().map(|k| k.id.clone())
250    }
251}
252
253/// 从 rmcp `RequestContext` 提取 HTTP `Authorization: Bearer <token>` —— 仅 HTTP
254/// transport 有效(streamable_http_server 把 `http::request::Parts` 塞进
255/// `ctx.extensions`)。stdio 下 `Parts` 不存在 → 返回 None。
256///
257/// 优先级:tool args 里的 `api_key` 字段 > HTTP Bearer header > startup key。
258/// 这里只负责提取 header 部分;调用方用 `req.api_key.as_deref().or(header)`
259/// 决定最终 override_key。
260fn http_bearer_token(ctx: &RequestContext<RoleServer>) -> Option<String> {
261    let parts = ctx.extensions.get::<http::request::Parts>()?;
262    parts
263        .headers
264        .get("authorization")
265        .and_then(|v| v.to_str().ok())
266        .and_then(|v| v.strip_prefix("Bearer ").map(|s| s.trim().to_string()))
267        .filter(|s| !s.is_empty())
268}
269
270#[tool_router(server_handler)]
271impl FutuServer {
272    // ------- 核心 -------
273
274    #[tool(description = "Ping the Futu gateway. Returns RTT and connection status.")]
275    async fn futu_ping(&self) -> String {
276        if let Some(rej) = self.require_tool_scope("futu_ping") {
277            return rej;
278        }
279        let gateway = self.state.inner.lock().await.gateway.clone();
280        let client = match self.state.client().await {
281            Ok(c) => c,
282            Err(e) => {
283                tracing::info!(tool = "futu_ping", ok = false, "connect failed: {e}");
284                return serde_json::to_string_pretty(&handlers::core::PingOut {
285                    gateway,
286                    ok: false,
287                    rtt_ms: 0.0,
288                    message: format!("connect failed: {e}"),
289                })
290                .unwrap_or_default();
291            }
292        };
293        let out = handlers::core::ping(&client, &gateway).await;
294        tracing::info!(tool = "futu_ping", ok = out.ok, rtt_ms = out.rtt_ms);
295        serde_json::to_string_pretty(&out).unwrap_or_default()
296    }
297
298    #[tool(
299        description = "Get real-time basic quote (price, volume, turnover) for a security. Auto-subscribes SubType::Basic on first call."
300    )]
301    async fn futu_get_quote(&self, Parameters(req): Parameters<SymbolReq>) -> String {
302        if let Some(rej) = self.require_tool_scope("futu_get_quote") {
303            return rej;
304        }
305        tracing::info!(tool = "futu_get_quote", symbol = %req.symbol);
306        let client = match self.client_or_err().await {
307            Ok(c) => c,
308            Err(e) => return e,
309        };
310        Self::wrap(handlers::core::get_quote(&client, &req.symbol).await)
311    }
312
313    #[tool(
314        description = "Get a security snapshot (one-shot, no subscription) with extended fields: 52-week high/low, avg price, volume ratio, amplitude, bid/ask."
315    )]
316    async fn futu_get_snapshot(&self, Parameters(req): Parameters<SymbolReq>) -> String {
317        if let Some(rej) = self.require_tool_scope("futu_get_snapshot") {
318            return rej;
319        }
320        tracing::info!(tool = "futu_get_snapshot", symbol = %req.symbol);
321        let client = match self.client_or_err().await {
322            Ok(c) => c,
323            Err(e) => return e,
324        };
325        Self::wrap(handlers::core::get_snapshot(&client, &req.symbol).await)
326    }
327
328    // ------- 行情 -------
329
330    #[tool(
331        description = "Get historical K-line (OHLCV). Supports day/week/month/quarter/year plus 1/3/5/15/30/60 minute bars."
332    )]
333    async fn futu_get_kline(&self, Parameters(req): Parameters<KLineReq>) -> String {
334        if let Some(rej) = self.require_tool_scope("futu_get_kline") {
335            return rej;
336        }
337        tracing::info!(
338            tool = "futu_get_kline",
339            symbol = %req.symbol,
340            kl_type = %req.kl_type,
341            count = ?req.count
342        );
343        let client = match self.client_or_err().await {
344            Ok(c) => c,
345            Err(e) => return e,
346        };
347        Self::wrap(
348            handlers::market::get_kline(
349                &client,
350                &req.symbol,
351                &req.kl_type,
352                req.count,
353                req.begin.as_deref(),
354                req.end.as_deref(),
355            )
356            .await,
357        )
358    }
359
360    #[tool(
361        description = "Get the order book (bids and asks with price, volume, order count). Auto-subscribes OrderBook."
362    )]
363    async fn futu_get_orderbook(&self, Parameters(req): Parameters<OrderBookReq>) -> String {
364        if let Some(rej) = self.require_tool_scope("futu_get_orderbook") {
365            return rej;
366        }
367        tracing::info!(tool = "futu_get_orderbook", symbol = %req.symbol, depth = req.depth);
368        let client = match self.client_or_err().await {
369            Ok(c) => c,
370            Err(e) => return e,
371        };
372        Self::wrap(handlers::market::get_orderbook(&client, &req.symbol, req.depth).await)
373    }
374
375    #[tool(description = "Get recent ticker (trade-by-trade). Auto-subscribes Ticker.")]
376    async fn futu_get_ticker(&self, Parameters(req): Parameters<TickerReq>) -> String {
377        if let Some(rej) = self.require_tool_scope("futu_get_ticker") {
378            return rej;
379        }
380        tracing::info!(tool = "futu_get_ticker", symbol = %req.symbol, count = req.count);
381        let client = match self.client_or_err().await {
382            Ok(c) => c,
383            Err(e) => return e,
384        };
385        Self::wrap(handlers::market::get_ticker(&client, &req.symbol, req.count).await)
386    }
387
388    #[tool(
389        description = "Get intraday (RT / time-sharing) minute-by-minute price series. Auto-subscribes RT."
390    )]
391    async fn futu_get_rt(&self, Parameters(req): Parameters<SymbolReq>) -> String {
392        if let Some(rej) = self.require_tool_scope("futu_get_rt") {
393            return rej;
394        }
395        tracing::info!(tool = "futu_get_rt", symbol = %req.symbol);
396        let client = match self.client_or_err().await {
397            Ok(c) => c,
398            Err(e) => return e,
399        };
400        Self::wrap(handlers::market::get_rt(&client, &req.symbol).await)
401    }
402
403    #[tool(
404        description = "Get static info (name, lot size, listing date) for one or more securities. No subscription needed."
405    )]
406    async fn futu_get_static(&self, Parameters(req): Parameters<SymbolListReq>) -> String {
407        if let Some(rej) = self.require_tool_scope("futu_get_static") {
408            return rej;
409        }
410        tracing::info!(tool = "futu_get_static", symbols = ?req.symbols);
411        let client = match self.client_or_err().await {
412            Ok(c) => c,
413            Err(e) => return e,
414        };
415        Self::wrap(handlers::market::get_static(&client, &req.symbols).await)
416    }
417
418    #[tool(description = "Get the broker queue (HK only). Auto-subscribes Broker.")]
419    async fn futu_get_broker(&self, Parameters(req): Parameters<SymbolReq>) -> String {
420        if let Some(rej) = self.require_tool_scope("futu_get_broker") {
421            return rej;
422        }
423        tracing::info!(tool = "futu_get_broker", symbol = %req.symbol);
424        let client = match self.client_or_err().await {
425            Ok(c) => c,
426            Err(e) => return e,
427        };
428        Self::wrap(handlers::market::get_broker(&client, &req.symbol).await)
429    }
430
431    // ------- 板块 -------
432
433    #[tool(description = "List plates by market and set type (industry / region / concept / all).")]
434    async fn futu_list_plates(&self, Parameters(req): Parameters<PlateListReq>) -> String {
435        if let Some(rej) = self.require_tool_scope("futu_list_plates") {
436            return rej;
437        }
438        tracing::info!(tool = "futu_list_plates", market = %req.market, set = %req.plate_set);
439        let client = match self.client_or_err().await {
440            Ok(c) => c,
441            Err(e) => return e,
442        };
443        Self::wrap(handlers::plate::list_plates(&client, &req.market, &req.plate_set).await)
444    }
445
446    #[tool(description = "List constituent securities of a plate.")]
447    async fn futu_plate_stocks(&self, Parameters(req): Parameters<PlateStocksReq>) -> String {
448        if let Some(rej) = self.require_tool_scope("futu_plate_stocks") {
449            return rej;
450        }
451        tracing::info!(tool = "futu_plate_stocks", plate = %req.plate);
452        let client = match self.client_or_err().await {
453            Ok(c) => c,
454            Err(e) => return e,
455        };
456        Self::wrap(handlers::plate::plate_stocks(&client, &req.plate).await)
457    }
458
459    // ------- 账户(只读) -------
460
461    #[tool(
462        description = "List all trading accounts (real + simulate) visible to the gateway login."
463    )]
464    async fn futu_list_accounts(&self) -> String {
465        if let Some(rej) = self.require_tool_scope("futu_list_accounts") {
466            return rej;
467        }
468        tracing::info!(tool = "futu_list_accounts");
469        let client = match self.client_or_err().await {
470            Ok(c) => c,
471            Err(e) => return e,
472        };
473        Self::wrap(handlers::trade::list_accounts(&client).await)
474    }
475
476    #[tool(
477        description = "Get account funds summary (total assets, cash, market value, buying power) for a given account + market."
478    )]
479    async fn futu_get_funds(&self, Parameters(req): Parameters<TrdAccReq>) -> String {
480        if let Some(rej) = self.require_tool_scope("futu_get_funds") {
481            return rej;
482        }
483        tracing::info!(tool = "futu_get_funds", market = %req.market, acc_id = req.acc_id, env = %req.env);
484        let client = match self.client_or_err().await {
485            Ok(c) => c,
486            Err(e) => return e,
487        };
488        Self::wrap(handlers::trade::get_funds(&client, &req.env, req.acc_id, &req.market).await)
489    }
490
491    #[tool(description = "Get current positions (holdings) for an account in a given market.")]
492    async fn futu_get_positions(&self, Parameters(req): Parameters<TrdAccReq>) -> String {
493        if let Some(rej) = self.require_tool_scope("futu_get_positions") {
494            return rej;
495        }
496        tracing::info!(tool = "futu_get_positions", market = %req.market, acc_id = req.acc_id);
497        let client = match self.client_or_err().await {
498            Ok(c) => c,
499            Err(e) => return e,
500        };
501        Self::wrap(handlers::trade::get_positions(&client, &req.env, req.acc_id, &req.market).await)
502    }
503
504    #[tool(
505        description = "Get today's orders (including pending / filled / cancelled) for an account in a given market."
506    )]
507    async fn futu_get_orders(&self, Parameters(req): Parameters<TrdAccReq>) -> String {
508        if let Some(rej) = self.require_tool_scope("futu_get_orders") {
509            return rej;
510        }
511        tracing::info!(tool = "futu_get_orders", market = %req.market, acc_id = req.acc_id);
512        let client = match self.client_or_err().await {
513            Ok(c) => c,
514            Err(e) => return e,
515        };
516        Self::wrap(handlers::trade::get_orders(&client, &req.env, req.acc_id, &req.market).await)
517    }
518
519    #[tool(description = "Get today's deals / order fills for an account in a given market.")]
520    async fn futu_get_deals(&self, Parameters(req): Parameters<TrdAccReq>) -> String {
521        if let Some(rej) = self.require_tool_scope("futu_get_deals") {
522            return rej;
523        }
524        tracing::info!(tool = "futu_get_deals", market = %req.market, acc_id = req.acc_id);
525        let client = match self.client_or_err().await {
526            Ok(c) => c,
527            Err(e) => return e,
528        };
529        Self::wrap(handlers::trade::get_deals(&client, &req.env, req.acc_id, &req.market).await)
530    }
531
532    // ------- 交易写入(需 --enable-trading) -------
533
534    #[tool(
535        description = "Place an order. REQUIRES futu-mcp to be started with --enable-trading. Real-env requires --allow-real-trading. Gateway must have been unlocked (password-based unlock_trade) beforehand."
536    )]
537    async fn futu_place_order(
538        &self,
539        Parameters(req): Parameters<PlaceOrderReq>,
540        req_ctx: RequestContext<RoleServer>,
541    ) -> String {
542        // per-call key 优先级:tool args `api_key` > HTTP Authorization Bearer > startup key
543        let header_token = http_bearer_token(&req_ctx);
544        let override_key = req.api_key.as_deref().or(header_token.as_deref());
545        let args_hash = guard::args_short_hash(&req);
546        tracing::warn!(
547            target: futu_auth::audit::TARGET,
548            iface = "mcp",
549            endpoint = "futu_place_order",
550            env = %req.env,
551            market = %req.market,
552            acc_id = req.acc_id,
553            side = %req.side,
554            order_type = %req.order_type,
555            code = %req.code,
556            qty = req.qty,
557            price = ?req.price,
558            args_hash = %args_hash,
559            outcome = "request",
560            "place_order request received"
561        );
562        let ctx = CheckCtx {
563            market: req.market.trim().to_ascii_uppercase(),
564            symbol: format!(
565                "{}.{}",
566                req.market.trim().to_ascii_uppercase(),
567                req.code.trim()
568            ),
569            order_value: req.price.map(|p| p * req.qty),
570            trd_side: Some(req.side.trim().to_ascii_uppercase()),
571        };
572        if let Some(rej) =
573            self.require_trading("futu_place_order", &req.env, Some(ctx), override_key)
574        {
575            return rej;
576        }
577        let client = match self.client_or_err().await {
578            Ok(c) => c,
579            Err(e) => return e,
580        };
581        let result = Self::wrap(
582            handlers::trade_write::place_order(
583                &client,
584                &req.env,
585                req.acc_id,
586                &req.market,
587                &req.side,
588                &req.order_type,
589                &req.code,
590                req.qty,
591                req.price,
592            )
593            .await,
594        );
595        guard::emit_trade_outcome(
596            "futu_place_order",
597            self.current_key_id(override_key).as_deref(),
598            &args_hash,
599            &result,
600        );
601        result
602    }
603
604    #[tool(
605        description = "Modify an existing order (change qty/price, cancel, disable/enable/delete). REQUIRES --enable-trading. For simple cancel, prefer futu_cancel_order."
606    )]
607    async fn futu_modify_order(
608        &self,
609        Parameters(req): Parameters<ModifyOrderReq>,
610        req_ctx: RequestContext<RoleServer>,
611    ) -> String {
612        let header_token = http_bearer_token(&req_ctx);
613        let override_key = req.api_key.as_deref().or(header_token.as_deref());
614        let args_hash = guard::args_short_hash(&req);
615        tracing::warn!(
616            target: futu_auth::audit::TARGET,
617            iface = "mcp",
618            endpoint = "futu_modify_order",
619            env = %req.env,
620            market = %req.market,
621            acc_id = req.acc_id,
622            order_id = req.order_id,
623            op = %req.op,
624            qty = ?req.qty,
625            price = ?req.price,
626            args_hash = %args_hash,
627            outcome = "request",
628            "modify_order request received"
629        );
630        // modify 改单 API 只给 order_id,symbol/金额/side 都推不出来 → 填空 ctx
631        // 好让 market 白名单 / 时段 / 速率限制照样生效(symbol / 单笔 / 日累计 / side
632        // 这几项在 limits.rs 里都已 skip-empty-ctx)
633        let mutation_ctx = CheckCtx {
634            market: req.market.trim().to_ascii_uppercase(),
635            symbol: String::new(),
636            order_value: None,
637            trd_side: None,
638        };
639        if let Some(rej) = self.require_trading(
640            "futu_modify_order",
641            &req.env,
642            Some(mutation_ctx),
643            override_key,
644        ) {
645            return rej;
646        }
647        let client = match self.client_or_err().await {
648            Ok(c) => c,
649            Err(e) => return e,
650        };
651        let result = Self::wrap(
652            handlers::trade_write::modify_order(
653                &client,
654                &req.env,
655                req.acc_id,
656                &req.market,
657                req.order_id,
658                &req.op,
659                req.qty,
660                req.price,
661            )
662            .await,
663        );
664        guard::emit_trade_outcome(
665            "futu_modify_order",
666            self.current_key_id(override_key).as_deref(),
667            &args_hash,
668            &result,
669        );
670        result
671    }
672
673    #[tool(
674        description = "Cancel an order by order_id. REQUIRES --enable-trading. Convenience wrapper over modify_order with op=CANCEL."
675    )]
676    async fn futu_cancel_order(
677        &self,
678        Parameters(req): Parameters<CancelOrderReq>,
679        req_ctx: RequestContext<RoleServer>,
680    ) -> String {
681        let header_token = http_bearer_token(&req_ctx);
682        let override_key = req.api_key.as_deref().or(header_token.as_deref());
683        let args_hash = guard::args_short_hash(&req);
684        tracing::warn!(
685            target: futu_auth::audit::TARGET,
686            iface = "mcp",
687            endpoint = "futu_cancel_order",
688            env = %req.env,
689            market = %req.market,
690            acc_id = req.acc_id,
691            order_id = req.order_id,
692            args_hash = %args_hash,
693            outcome = "request",
694            "cancel_order request received"
695        );
696        let mutation_ctx = CheckCtx {
697            market: req.market.trim().to_ascii_uppercase(),
698            symbol: String::new(),
699            order_value: None,
700            trd_side: None,
701        };
702        if let Some(rej) = self.require_trading(
703            "futu_cancel_order",
704            &req.env,
705            Some(mutation_ctx),
706            override_key,
707        ) {
708            return rej;
709        }
710        let client = match self.client_or_err().await {
711            Ok(c) => c,
712            Err(e) => return e,
713        };
714        let result = Self::wrap(
715            handlers::trade_write::cancel_order(
716                &client,
717                &req.env,
718                req.acc_id,
719                &req.market,
720                req.order_id,
721            )
722            .await,
723        );
724        guard::emit_trade_outcome(
725            "futu_cancel_order",
726            self.current_key_id(override_key).as_deref(),
727            &args_hash,
728            &result,
729        );
730        result
731    }
732}