Skip to content

MCP

Model Context Protocol server: exposes 19 gateway tools to LLM clients. Two transports: stdio / HTTP.

19 tools

Quote (qot:read, 11 tools)

Tool Description
futu_ping Ping the gateway, returns RTT
futu_get_quote Real-time quote (auto-subscribes Basic)
futu_get_snapshot Snapshot (52-week high/low, turnover, book)
futu_get_kline Historical candles (day / week / month / 1-60 min)
futu_get_orderbook Level-2 book (auto-subscribes OrderBook)
futu_get_ticker Tick-by-tick trades
futu_get_rt Intraday time series
futu_get_static Static info (name, lot size, list date)
futu_get_broker Broker queue (HK only)
futu_list_plates Plate/sector list
futu_plate_stocks Plate constituents

Account read (acc:read, 5 tools)

Tool Description
futu_list_accounts Trading account list
futu_get_funds Funds summary
futu_get_positions Positions
futu_get_orders Today's orders
futu_get_deals Today's fills

Trade (trade:real / trade:simulate, 3 tools)

Tool Description Extra args
futu_place_order Place order api_key? per-call override
futu_modify_order Modify / cancel api_key?
futu_cancel_order Cancel (shortcut) api_key?

Unlock trading (trade:unlock, v1.4+, 1 tool)

Tool Description Extra args
futu_unlock_trade Unlock / re-lock real trading unlock: bool (default true)
  • Plaintext password never enters LLM prompt: the server reads it from the OS keychain (preferred) or the FUTU_TRADE_PWD environment variable (fallback).
  • Ops writes the password into the keychain via futucli set-trade-pwd; the LLM only calls the futu_unlock_trade tool — password never flows through the model.
  • MD5 is computed server-side, then sent to the gateway.
  • After unlock, the gateway process caches the cipher until the gateway restarts; individual orders don't need to re-unlock.

Startup

export FUTU_MCP_API_KEY="fc_xxxx..."
./futu-mcp \
  --gateway 127.0.0.1:11111 \
  --keys-file ~/.config/futu/keys.json

JSON-RPC frames over stdin/stdout, launched by the LLM client as a child process.

./futu-mcp \
  --gateway 127.0.0.1:11111 \
  --keys-file ~/.config/futu/keys.json \
  --http-listen 127.0.0.1:38765
  • POST /mcp — rmcp streamable HTTP transport
  • GET /metrics — Prometheus (no token required)
  • GET /.well-known/oauth-protected-resource — OAuth 2.0 Protected Resource Metadata (RFC 9728, v1.4+; lets MCP clients auto-discover scope requirements)

OAuth metadata endpoint (v1.4+)

A /mcp request without Authorization: Bearer returns 401 + WWW-Authenticate: Bearer resource_metadata="/.well-known/oauth-protected-resource". The client fetches that URL to learn the required scopes:

{
  "resource": "/mcp",
  "bearer_methods_supported": ["header"],
  "scopes_supported": [
    "qot:read",
    "acc:read",
    "trade:simulate",
    "trade:real",
    "trade:unlock"
  ],
  "resource_name": "FutuOpenD-rs MCP",
  "resource_documentation": "https://futuapi.com/guide/mcp/"
}

Why not a full OAuth server?

Futu API keys are manually-configured long-lived credentials — there's no interactive consent flow. RFC 9728 is designed exactly for "declare the auth requirement, don't perform the flow" resource servers. The actual token (= API key) is still handed out out-of-band by ops and written into the MCP client config's Authorization header.

Per-call API key precedence (v1.1+)

1. `api_key` field in the tool args (explicit)
2. HTTP Authorization: Bearer <token> (HTTP transport only)
3. Startup-time --api-key / FUTU_MCP_API_KEY env var

stdio mode has no HTTP header → falls back 2→3. HTTP mode lets multiple LLMs each carry their own token, with audit / limits tracked independently.

Central scope registry

guard::scope_for_tool(name) -> Option<ToolScope> is the single source of truth. Adding a new #[tool] requires updating this registry — otherwise the handler returns unknown MCP tool and the all_known_tools_have_scopes test breaks.

SIGHUP hot reload

kill -HUP $(pgrep futu-mcp)

Changes to a key's scope / limits / expires_at / revocation take effect immediately on MCP (since v0.8+, MCP looks up KeyStore on every request by key_id).

Trading-tool security boundary

  • unlock_trade takes no password arg (v1.4+) — futu_unlock_trade only has unlock: bool; the server reads the password from the OS keychain / env var. The LLM prompt and tool-call log never see the password.
  • Simulate by defaultPlaceOrderReq.env defaults to "simulate"; the LLM must explicitly pass "real" to hit the real account.
  • Scope isolationtrade:simulate can never place a real order (even if env is spoofed to real, require_trading rejects it).
  • Limits always apply — the handler runs the full CheckCtx: market / symbol / value / side / daily checks.
  • WS push defense in depth (v1.4+) — even if the subscription gate slips, push dispatch runs another scope filter; filtered pushes increment futu_ws_push_filtered_total.

MCP client integration

See Tutorial: MCP integration for Claude Desktop / Cursor / Continue configuration examples.