futu-stock-mcp-server 0.1.2__py3-none-any.whl → 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of futu-stock-mcp-server might be problematic. Click here for more details.
- futu_stock_mcp_server/server.py +432 -71
- {futu_stock_mcp_server-0.1.2.dist-info → futu_stock_mcp_server-0.1.4.dist-info}/METADATA +20 -5
- futu_stock_mcp_server-0.1.4.dist-info/RECORD +7 -0
- futu_stock_mcp_server-0.1.2.dist-info/RECORD +0 -7
- {futu_stock_mcp_server-0.1.2.dist-info → futu_stock_mcp_server-0.1.4.dist-info}/WHEEL +0 -0
- {futu_stock_mcp_server-0.1.2.dist-info → futu_stock_mcp_server-0.1.4.dist-info}/entry_points.txt +0 -0
- {futu_stock_mcp_server-0.1.2.dist-info → futu_stock_mcp_server-0.1.4.dist-info}/licenses/LICENSE +0 -0
futu_stock_mcp_server/server.py
CHANGED
|
@@ -1,16 +1,93 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import warnings
|
|
4
|
+
import logging
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
# CRITICAL: Check if this is a help command before setting MCP mode
|
|
8
|
+
_is_help_command = any(arg in ['--help', '-h', '--version', '-v'] for arg in sys.argv)
|
|
9
|
+
|
|
10
|
+
# CRITICAL: Set MCP mode BEFORE any logging to ensure clean stdout
|
|
11
|
+
# But not if this is a help command - in that case, we want normal stdout
|
|
12
|
+
if not _is_help_command:
|
|
13
|
+
os.environ['MCP_MODE'] = os.environ.get('MCP_MODE', '1')
|
|
14
|
+
|
|
15
|
+
# CRITICAL: Completely disable all potential stdout pollution sources
|
|
16
|
+
# This must be done BEFORE any other imports or operations
|
|
17
|
+
|
|
18
|
+
# 1. Disable all warnings that might go to stdout/stderr
|
|
19
|
+
warnings.filterwarnings("ignore")
|
|
20
|
+
warnings.simplefilter("ignore")
|
|
21
|
+
|
|
22
|
+
# 2. Completely disable the standard logging system
|
|
23
|
+
logging.disable(logging.CRITICAL)
|
|
24
|
+
|
|
25
|
+
# 3. Set environment variables to prevent ANSI escape sequences
|
|
26
|
+
os.environ['NO_COLOR'] = '1'
|
|
27
|
+
os.environ['TERM'] = 'dumb'
|
|
28
|
+
os.environ['FORCE_COLOR'] = '0'
|
|
29
|
+
os.environ['COLORTERM'] = ''
|
|
30
|
+
os.environ['ANSI_COLORS_DISABLED'] = '1'
|
|
31
|
+
os.environ['PYTHONUNBUFFERED'] = '1'
|
|
32
|
+
os.environ['PYTHONIOENCODING'] = 'utf-8'
|
|
33
|
+
|
|
34
|
+
# 4. Create a custom stdout wrapper to catch any accidental writes
|
|
35
|
+
class StdoutProtector:
|
|
36
|
+
"""Protects stdout from any non-MCP content"""
|
|
37
|
+
def __init__(self, original_stdout):
|
|
38
|
+
self.original = original_stdout
|
|
39
|
+
self.buffer = ""
|
|
40
|
+
|
|
41
|
+
def write(self, text):
|
|
42
|
+
# Only allow JSON-like content or empty strings
|
|
43
|
+
if not text or text.isspace():
|
|
44
|
+
self.original.write(text)
|
|
45
|
+
elif text.strip().startswith(('{', '[', '"')) or text.strip() == '':
|
|
46
|
+
self.original.write(text)
|
|
47
|
+
else:
|
|
48
|
+
# Silently drop non-JSON content
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
def flush(self):
|
|
52
|
+
self.original.flush()
|
|
53
|
+
|
|
54
|
+
def __getattr__(self, name):
|
|
55
|
+
return getattr(self.original, name)
|
|
56
|
+
|
|
57
|
+
# Apply stdout protection in MCP mode VERY EARLY, before any imports
|
|
58
|
+
if os.getenv('MCP_MODE') == '1':
|
|
59
|
+
sys.stdout = StdoutProtector(sys.stdout)
|
|
60
|
+
|
|
61
|
+
# 5. Redirect stderr to null in MCP mode to prevent any accidental output
|
|
62
|
+
_stderr_redirected = False
|
|
63
|
+
_stderr_backup = None
|
|
64
|
+
if os.getenv('MCP_MODE') == '1':
|
|
65
|
+
# Save original stderr for emergency use
|
|
66
|
+
_stderr_backup = sys.stderr
|
|
67
|
+
# Redirect stderr to devnull
|
|
68
|
+
devnull = open(os.devnull, 'w')
|
|
69
|
+
sys.stderr = devnull
|
|
70
|
+
_stderr_redirected = True
|
|
71
|
+
|
|
72
|
+
# Now we can safely import other modules
|
|
1
73
|
from contextlib import asynccontextmanager
|
|
2
74
|
from collections.abc import AsyncIterator
|
|
3
75
|
from typing import Dict, Any, List, Optional
|
|
4
|
-
|
|
76
|
+
try:
|
|
77
|
+
from futu import OpenQuoteContext, OpenSecTradeContext, TrdMarket, SecurityFirm, RET_OK
|
|
78
|
+
except ImportError as e:
|
|
79
|
+
# In MCP mode, we should avoid printing to stdout/stderr
|
|
80
|
+
# Log to file only
|
|
81
|
+
logger.error(f"Failed to import futu: {e}")
|
|
82
|
+
sys.exit(1)
|
|
5
83
|
import json
|
|
6
84
|
import asyncio
|
|
7
85
|
from loguru import logger
|
|
8
|
-
import os
|
|
9
|
-
import sys
|
|
10
86
|
from dotenv import load_dotenv
|
|
11
|
-
from mcp.server.fastmcp import FastMCP
|
|
87
|
+
from mcp.server.fastmcp import FastMCP, Context
|
|
12
88
|
from mcp.types import TextContent, PromptMessage
|
|
13
89
|
from mcp.server import Server
|
|
90
|
+
from mcp.server.session import ServerSession
|
|
14
91
|
import atexit
|
|
15
92
|
import signal
|
|
16
93
|
import fcntl
|
|
@@ -18,45 +95,163 @@ import psutil
|
|
|
18
95
|
import time
|
|
19
96
|
from datetime import datetime
|
|
20
97
|
|
|
21
|
-
# Get the
|
|
22
|
-
|
|
23
|
-
|
|
98
|
+
# Get the user home directory and create logs directory there
|
|
99
|
+
home_dir = os.path.expanduser("~")
|
|
100
|
+
log_dir = os.path.join(home_dir, "logs")
|
|
101
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
24
102
|
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
103
|
+
# CRITICAL: Configure logging to be MCP-compatible
|
|
104
|
+
# According to MCP best practices, we should:
|
|
105
|
+
# 1. Never write to stdout (reserved for MCP JSON communication)
|
|
106
|
+
# 2. Use file logging for debugging
|
|
107
|
+
# 3. Use MCP Context for operational logging when available
|
|
108
|
+
# 4. Suppress third-party library logs that might pollute output
|
|
28
109
|
|
|
29
|
-
#
|
|
30
|
-
|
|
110
|
+
# Completely silence warnings and third-party logs
|
|
111
|
+
warnings.filterwarnings("ignore")
|
|
31
112
|
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
os.makedirs(log_dir, exist_ok=True)
|
|
113
|
+
# Configure loguru for file-only logging
|
|
114
|
+
logger.remove() # Remove all default handlers
|
|
35
115
|
|
|
36
|
-
#
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
116
|
+
# CRITICAL: In MCP mode, ensure NO stderr output at all
|
|
117
|
+
if os.getenv('MCP_MODE') == '1':
|
|
118
|
+
# Remove any remaining handlers that might output to stderr
|
|
119
|
+
logger.remove()
|
|
120
|
+
# Add only file handler - NO console output
|
|
121
|
+
logger.add(
|
|
122
|
+
os.path.join(log_dir, "futu_mcp_server.log"),
|
|
123
|
+
rotation="500 MB",
|
|
124
|
+
retention="10 days",
|
|
125
|
+
level="DEBUG",
|
|
126
|
+
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {message}",
|
|
127
|
+
enqueue=True, # Thread-safe logging
|
|
128
|
+
backtrace=True,
|
|
129
|
+
diagnose=True
|
|
130
|
+
)
|
|
131
|
+
else:
|
|
132
|
+
# Non-MCP mode: add file handler
|
|
133
|
+
logger.add(
|
|
134
|
+
os.path.join(log_dir, "futu_mcp_server.log"),
|
|
135
|
+
rotation="500 MB",
|
|
136
|
+
retention="10 days",
|
|
137
|
+
level="DEBUG",
|
|
138
|
+
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {message}",
|
|
139
|
+
enqueue=True, # Thread-safe logging
|
|
140
|
+
backtrace=True,
|
|
141
|
+
diagnose=True
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Only add stderr logging if explicitly in debug mode and not in MCP mode
|
|
145
|
+
if os.getenv('FUTU_DEBUG_MODE') == '1' and not os.getenv('MCP_MODE') == '1':
|
|
146
|
+
logger.add(
|
|
147
|
+
sys.stderr,
|
|
148
|
+
level="INFO",
|
|
149
|
+
format="{time:HH:mm:ss} | {level} | {message}",
|
|
150
|
+
colorize=False,
|
|
151
|
+
filter=lambda record: record["level"].name in ["INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Suppress all third-party library logging to prevent stdout pollution
|
|
155
|
+
logging.disable(logging.CRITICAL)
|
|
58
156
|
|
|
59
|
-
|
|
157
|
+
# Set up null handlers for problematic loggers
|
|
158
|
+
class NullHandler(logging.Handler):
|
|
159
|
+
def emit(self, record):
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
null_handler = NullHandler()
|
|
163
|
+
root_logger = logging.getLogger()
|
|
164
|
+
root_logger.addHandler(null_handler)
|
|
165
|
+
root_logger.setLevel(logging.CRITICAL + 1)
|
|
166
|
+
|
|
167
|
+
# Specifically silence known problematic loggers
|
|
168
|
+
for logger_name in [
|
|
169
|
+
'mcp', 'fastmcp', 'futu', 'uvicorn', 'asyncio',
|
|
170
|
+
'websockets', 'aiohttp', 'urllib3', 'requests'
|
|
171
|
+
]:
|
|
172
|
+
lib_logger = logging.getLogger(logger_name)
|
|
173
|
+
lib_logger.disabled = True
|
|
174
|
+
lib_logger.addHandler(null_handler)
|
|
175
|
+
lib_logger.setLevel(logging.CRITICAL + 1)
|
|
176
|
+
lib_logger.propagate = False
|
|
177
|
+
|
|
178
|
+
# Even more aggressive suppression for futu library
|
|
179
|
+
# This is critical because futu library may output logs during connection
|
|
180
|
+
try:
|
|
181
|
+
# Suppress futu library logging completely
|
|
182
|
+
futu_logger = logging.getLogger('futu')
|
|
183
|
+
futu_logger.disabled = True
|
|
184
|
+
futu_logger.setLevel(logging.CRITICAL + 1)
|
|
185
|
+
futu_logger.propagate = False
|
|
186
|
+
|
|
187
|
+
# Suppress specific futu sub-modules that are known to output logs
|
|
188
|
+
for sub_logger_name in [
|
|
189
|
+
'futu', 'futu.common', 'futu.quote', 'futu.trade',
|
|
190
|
+
'futu.common.constant', 'futu.common.sys_utils',
|
|
191
|
+
'futu.quote.open_quote_context', 'futu.quote.quote_response_handler',
|
|
192
|
+
'futu.trade.open_trade_context', 'futu.trade.trade_response_handler',
|
|
193
|
+
'futu.common.open_context_base', 'futu.common.network_manager'
|
|
194
|
+
]:
|
|
195
|
+
sub_logger = logging.getLogger(sub_logger_name)
|
|
196
|
+
sub_logger.disabled = True
|
|
197
|
+
sub_logger.setLevel(logging.CRITICAL + 1)
|
|
198
|
+
sub_logger.propagate = False
|
|
199
|
+
|
|
200
|
+
# Also redirect any direct print statements from futu to a file
|
|
201
|
+
if os.getenv('MCP_MODE') == '1':
|
|
202
|
+
# Create a special log file for futu connection logs
|
|
203
|
+
home_dir = os.path.expanduser("~")
|
|
204
|
+
log_dir = os.path.join(home_dir, "logs")
|
|
205
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
206
|
+
|
|
207
|
+
futu_conn_log_file = os.path.join(log_dir, "futu_connection.log")
|
|
208
|
+
futu_conn_log = open(futu_conn_log_file, 'a')
|
|
209
|
+
|
|
210
|
+
# This is a last resort to catch any print statements from futu
|
|
211
|
+
# We'll redirect stderr to this file temporarily during connection
|
|
212
|
+
# and then restore it to devnull
|
|
213
|
+
except Exception as e:
|
|
214
|
+
# If we can't set up additional logging, continue anyway
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
# MCP-compatible logging helper functions
|
|
218
|
+
async def log_to_mcp(ctx: Context, level: str, message: str):
|
|
219
|
+
"""Send log message through MCP Context when available"""
|
|
220
|
+
try:
|
|
221
|
+
if level.upper() == "DEBUG":
|
|
222
|
+
await ctx.debug(message)
|
|
223
|
+
elif level.upper() == "INFO":
|
|
224
|
+
await ctx.info(message)
|
|
225
|
+
elif level.upper() == "WARNING":
|
|
226
|
+
await ctx.warning(message)
|
|
227
|
+
elif level.upper() == "ERROR":
|
|
228
|
+
await ctx.error(message)
|
|
229
|
+
else:
|
|
230
|
+
await ctx.info(f"[{level}] {message}")
|
|
231
|
+
except Exception:
|
|
232
|
+
# Fallback to file logging if MCP context fails
|
|
233
|
+
logger.log(level.upper(), message)
|
|
234
|
+
|
|
235
|
+
def safe_log(level: str, message: str, ctx: Context = None):
|
|
236
|
+
"""Safe logging that uses MCP context when available, file logging otherwise"""
|
|
237
|
+
# Always log to file
|
|
238
|
+
logger.log(level.upper(), message)
|
|
239
|
+
|
|
240
|
+
# Also send to MCP if context is available
|
|
241
|
+
if ctx and os.getenv('MCP_MODE') == '1' and not _stderr_redirected:
|
|
242
|
+
try:
|
|
243
|
+
import asyncio
|
|
244
|
+
loop = asyncio.get_event_loop()
|
|
245
|
+
if loop.is_running():
|
|
246
|
+
asyncio.create_task(log_to_mcp(ctx, level, message))
|
|
247
|
+
except Exception:
|
|
248
|
+
pass # Ignore MCP logging errors
|
|
249
|
+
|
|
250
|
+
# Only log to file, never to stdout/stderr in MCP mode
|
|
251
|
+
# These logs will only be written to file, not to stdout/stderr
|
|
252
|
+
|
|
253
|
+
# Get project root directory
|
|
254
|
+
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
60
255
|
|
|
61
256
|
# PID file path
|
|
62
257
|
PID_FILE = os.path.join(project_root, '.futu_mcp.pid')
|
|
@@ -359,9 +554,103 @@ def init_trade_connection():
|
|
|
359
554
|
_is_trade_initialized = False
|
|
360
555
|
return False
|
|
361
556
|
|
|
362
|
-
def init_futu_connection():
|
|
363
|
-
"""
|
|
364
|
-
|
|
557
|
+
def init_futu_connection() -> bool:
|
|
558
|
+
"""
|
|
559
|
+
Initialize connection to Futu OpenD.
|
|
560
|
+
Returns True if successful, False otherwise.
|
|
561
|
+
"""
|
|
562
|
+
global quote_ctx, trade_ctx, _is_trade_initialized
|
|
563
|
+
|
|
564
|
+
try:
|
|
565
|
+
# Get connection parameters from environment
|
|
566
|
+
host = os.getenv('FUTU_HOST', '127.0.0.1')
|
|
567
|
+
port = int(os.getenv('FUTU_PORT', '11111'))
|
|
568
|
+
|
|
569
|
+
# Log to file only
|
|
570
|
+
logger.info(f"Initializing Futu connection to {host}:{port}")
|
|
571
|
+
|
|
572
|
+
# Temporarily redirect stderr to capture any futu library output
|
|
573
|
+
original_stderr = None
|
|
574
|
+
futu_log_file = None
|
|
575
|
+
futu_log_fd = None
|
|
576
|
+
|
|
577
|
+
if os.getenv('MCP_MODE') == '1' and _stderr_redirected:
|
|
578
|
+
try:
|
|
579
|
+
# Create a special log file for futu connection logs
|
|
580
|
+
home_dir = os.path.expanduser("~")
|
|
581
|
+
log_dir = os.path.join(home_dir, "logs")
|
|
582
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
583
|
+
futu_log_file = os.path.join(log_dir, "futu_connection.log")
|
|
584
|
+
|
|
585
|
+
# Save current stderr (should be devnull)
|
|
586
|
+
original_stderr = sys.stderr
|
|
587
|
+
|
|
588
|
+
# Redirect stderr to futu log file temporarily
|
|
589
|
+
futu_log_fd = open(futu_log_file, 'a')
|
|
590
|
+
sys.stderr = futu_log_fd
|
|
591
|
+
except Exception as e:
|
|
592
|
+
logger.debug(f"Could not redirect stderr for futu connection: {e}")
|
|
593
|
+
|
|
594
|
+
try:
|
|
595
|
+
# Initialize quote context
|
|
596
|
+
quote_ctx = OpenQuoteContext(host=host, port=port)
|
|
597
|
+
|
|
598
|
+
# Initialize trade context if needed
|
|
599
|
+
if os.getenv('FUTU_ENABLE_TRADING', '0') == '1':
|
|
600
|
+
# Get trading parameters
|
|
601
|
+
trade_env = os.getenv('FUTU_TRADE_ENV', 'SIMULATE')
|
|
602
|
+
security_firm = os.getenv('FUTU_SECURITY_FIRM', 'FUTUSECURITIES')
|
|
603
|
+
trd_market = os.getenv('FUTU_TRD_MARKET', 'HK')
|
|
604
|
+
|
|
605
|
+
# Map environment strings to Futu enums
|
|
606
|
+
trade_env_enum = {
|
|
607
|
+
'REAL': TrdMarket.REAL,
|
|
608
|
+
'SIMULATE': TrdMarket.SIMULATE
|
|
609
|
+
}.get(trade_env, TrdMarket.SIMULATE)
|
|
610
|
+
|
|
611
|
+
security_firm_enum = {
|
|
612
|
+
'FUTUSECURITIES': SecurityFirm.FUTUSECURITIES,
|
|
613
|
+
'FUTUINC': SecurityFirm.FUTUINC
|
|
614
|
+
}.get(security_firm, SecurityFirm.FUTUSECURITIES)
|
|
615
|
+
|
|
616
|
+
trd_market_enum = {
|
|
617
|
+
'HK': TrdMarket.HK,
|
|
618
|
+
'US': TrdMarket.US,
|
|
619
|
+
'CN': TrdMarket.CN,
|
|
620
|
+
'HKCC': TrdMarket.HKCC,
|
|
621
|
+
'AU': TrdMarket.AU
|
|
622
|
+
}.get(trd_market, TrdMarket.HK)
|
|
623
|
+
|
|
624
|
+
# Initialize trade context
|
|
625
|
+
trade_ctx = OpenSecTradeContext(
|
|
626
|
+
host=host,
|
|
627
|
+
port=port,
|
|
628
|
+
security_firm=security_firm_enum
|
|
629
|
+
)
|
|
630
|
+
_is_trade_initialized = True
|
|
631
|
+
logger.info("Trade context initialized successfully")
|
|
632
|
+
|
|
633
|
+
logger.info("Futu connection initialized successfully")
|
|
634
|
+
return True
|
|
635
|
+
|
|
636
|
+
finally:
|
|
637
|
+
# Restore stderr redirection
|
|
638
|
+
if futu_log_fd:
|
|
639
|
+
try:
|
|
640
|
+
futu_log_fd.close()
|
|
641
|
+
except:
|
|
642
|
+
pass
|
|
643
|
+
# Restore original stderr (should be devnull)
|
|
644
|
+
if original_stderr:
|
|
645
|
+
sys.stderr = original_stderr
|
|
646
|
+
|
|
647
|
+
except Exception as e:
|
|
648
|
+
error_msg = f"Failed to initialize Futu connection: {str(e)}"
|
|
649
|
+
logger.error(error_msg)
|
|
650
|
+
# Make sure we restore stderr even in case of errors
|
|
651
|
+
if _stderr_redirected and _stderr_backup:
|
|
652
|
+
sys.stderr = _stderr_backup
|
|
653
|
+
return False
|
|
365
654
|
|
|
366
655
|
@asynccontextmanager
|
|
367
656
|
async def lifespan(server: Server):
|
|
@@ -411,7 +700,7 @@ def handle_return_data(ret: int, data: Any) -> Dict[str, Any]:
|
|
|
411
700
|
|
|
412
701
|
# Market Data Tools
|
|
413
702
|
@mcp.tool()
|
|
414
|
-
async def get_stock_quote(symbols: List[str]) -> Dict[str, Any]:
|
|
703
|
+
async def get_stock_quote(symbols: List[str], ctx: Context[ServerSession, None] = None) -> Dict[str, Any]:
|
|
415
704
|
"""Get stock quote data for given symbols
|
|
416
705
|
|
|
417
706
|
Args:
|
|
@@ -455,21 +744,32 @@ async def get_stock_quote(symbols: List[str]) -> Dict[str, Any]:
|
|
|
455
744
|
- Consider actual needs when selecting stocks
|
|
456
745
|
- Handle exceptions properly
|
|
457
746
|
"""
|
|
458
|
-
|
|
459
|
-
if ret != RET_OK:
|
|
460
|
-
return {'error': str(data)}
|
|
747
|
+
safe_log("info", f"Getting stock quotes for symbols: {symbols}", ctx)
|
|
461
748
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
result = {
|
|
469
|
-
'quote_list': data
|
|
470
|
-
}
|
|
749
|
+
try:
|
|
750
|
+
ret, data = quote_ctx.get_stock_quote(symbols)
|
|
751
|
+
if ret != RET_OK:
|
|
752
|
+
error_msg = f"Failed to get stock quote: {str(data)}"
|
|
753
|
+
safe_log("error", error_msg, ctx)
|
|
754
|
+
return {'error': error_msg}
|
|
471
755
|
|
|
472
|
-
|
|
756
|
+
# Convert DataFrame to dict if necessary
|
|
757
|
+
if hasattr(data, 'to_dict'):
|
|
758
|
+
result = {
|
|
759
|
+
'quote_list': data.to_dict('records')
|
|
760
|
+
}
|
|
761
|
+
else:
|
|
762
|
+
result = {
|
|
763
|
+
'quote_list': data
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
safe_log("info", f"Successfully retrieved quotes for {len(symbols)} symbols", ctx)
|
|
767
|
+
return result
|
|
768
|
+
|
|
769
|
+
except Exception as e:
|
|
770
|
+
error_msg = f"Exception in get_stock_quote: {str(e)}"
|
|
771
|
+
safe_log("error", error_msg, ctx)
|
|
772
|
+
return {'error': error_msg}
|
|
473
773
|
|
|
474
774
|
@mcp.tool()
|
|
475
775
|
async def get_market_snapshot(symbols: List[str]) -> Dict[str, Any]:
|
|
@@ -1126,12 +1426,29 @@ async def get_option_butterfly(symbol: str, expiry: str, strike_price: float) ->
|
|
|
1126
1426
|
|
|
1127
1427
|
# Account Query Tools
|
|
1128
1428
|
@mcp.tool()
|
|
1129
|
-
async def get_account_list() -> Dict[str, Any]:
|
|
1429
|
+
async def get_account_list(ctx: Context[ServerSession, None] = None) -> Dict[str, Any]:
|
|
1130
1430
|
"""Get account list"""
|
|
1431
|
+
safe_log("info", "Attempting to get account list", ctx)
|
|
1432
|
+
|
|
1131
1433
|
if not init_trade_connection():
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1434
|
+
error_msg = 'Failed to initialize trade connection'
|
|
1435
|
+
safe_log("error", error_msg, ctx)
|
|
1436
|
+
return {'error': error_msg}
|
|
1437
|
+
|
|
1438
|
+
try:
|
|
1439
|
+
ret, data = trade_ctx.get_acc_list()
|
|
1440
|
+
result = handle_return_data(ret, data)
|
|
1441
|
+
|
|
1442
|
+
if 'error' not in result:
|
|
1443
|
+
safe_log("info", "Successfully retrieved account list", ctx)
|
|
1444
|
+
else:
|
|
1445
|
+
safe_log("error", f"Failed to get account list: {result['error']}", ctx)
|
|
1446
|
+
|
|
1447
|
+
return result
|
|
1448
|
+
except Exception as e:
|
|
1449
|
+
error_msg = f"Exception in get_account_list: {str(e)}"
|
|
1450
|
+
safe_log("error", error_msg, ctx)
|
|
1451
|
+
return {'error': error_msg}
|
|
1135
1452
|
|
|
1136
1453
|
@mcp.tool()
|
|
1137
1454
|
async def get_funds() -> Dict[str, Any]:
|
|
@@ -1456,47 +1773,91 @@ async def get_current_time() -> Dict[str, Any]:
|
|
|
1456
1773
|
|
|
1457
1774
|
def main():
|
|
1458
1775
|
"""Main entry point for the futu-mcp-server command."""
|
|
1776
|
+
# Parse command line arguments first
|
|
1777
|
+
parser = argparse.ArgumentParser(
|
|
1778
|
+
description="Futu Stock MCP Server - A Model Context Protocol server for accessing Futu OpenAPI functionality",
|
|
1779
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1780
|
+
epilog="""
|
|
1781
|
+
Examples:
|
|
1782
|
+
futu-mcp-server # Start the MCP server
|
|
1783
|
+
futu-mcp-server --help # Show this help message
|
|
1784
|
+
|
|
1785
|
+
Environment Variables:
|
|
1786
|
+
FUTU_HOST # Futu OpenD host (default: 127.0.0.1)
|
|
1787
|
+
FUTU_PORT # Futu OpenD port (default: 11111)
|
|
1788
|
+
FUTU_ENABLE_TRADING # Enable trading features (default: 0)
|
|
1789
|
+
FUTU_TRADE_ENV # Trading environment: SIMULATE or REAL (default: SIMULATE)
|
|
1790
|
+
FUTU_SECURITY_FIRM # Security firm: FUTUSECURITIES or FUTUINC (default: FUTUSECURITIES)
|
|
1791
|
+
FUTU_TRD_MARKET # Trading market: HK or US (default: HK)
|
|
1792
|
+
FUTU_DEBUG_MODE # Enable debug logging (default: 0)
|
|
1793
|
+
"""
|
|
1794
|
+
)
|
|
1795
|
+
|
|
1796
|
+
parser.add_argument(
|
|
1797
|
+
'--version',
|
|
1798
|
+
action='version',
|
|
1799
|
+
version='futu-stock-mcp-server 0.1.3'
|
|
1800
|
+
)
|
|
1801
|
+
|
|
1802
|
+
args = parser.parse_args()
|
|
1803
|
+
|
|
1459
1804
|
try:
|
|
1460
|
-
#
|
|
1805
|
+
# CRITICAL: Set MCP mode BEFORE any logging to ensure clean stdout
|
|
1806
|
+
os.environ['MCP_MODE'] = '1'
|
|
1807
|
+
|
|
1808
|
+
# Ensure no color output or ANSI escape sequences in MCP mode
|
|
1461
1809
|
os.environ['NO_COLOR'] = '1'
|
|
1462
1810
|
os.environ['TERM'] = 'dumb'
|
|
1811
|
+
os.environ['FORCE_COLOR'] = '0'
|
|
1812
|
+
os.environ['COLORTERM'] = ''
|
|
1813
|
+
os.environ['ANSI_COLORS_DISABLED'] = '1'
|
|
1814
|
+
os.environ['PYTHONUNBUFFERED'] = '1'
|
|
1815
|
+
os.environ['PYTHONIOENCODING'] = 'utf-8'
|
|
1463
1816
|
|
|
1464
|
-
#
|
|
1817
|
+
# Disable Python buffering to ensure clean MCP JSON communication
|
|
1818
|
+
|
|
1819
|
+
# Clean up stale processes and acquire lock
|
|
1465
1820
|
cleanup_stale_processes()
|
|
1466
|
-
|
|
1467
|
-
# 获取锁
|
|
1821
|
+
|
|
1468
1822
|
lock_fd = acquire_lock()
|
|
1469
1823
|
if lock_fd is None:
|
|
1824
|
+
# Use file logging only - no stderr output in MCP mode
|
|
1470
1825
|
logger.error("Failed to acquire lock. Another instance may be running.")
|
|
1471
1826
|
sys.exit(1)
|
|
1472
1827
|
|
|
1473
|
-
#
|
|
1828
|
+
# Set up signal handlers
|
|
1474
1829
|
signal.signal(signal.SIGINT, signal_handler)
|
|
1475
1830
|
signal.signal(signal.SIGTERM, signal_handler)
|
|
1476
1831
|
|
|
1477
|
-
|
|
1832
|
+
# Initialize Futu connection with file logging only
|
|
1833
|
+
logger.info("Initializing Futu connection for MCP server...")
|
|
1478
1834
|
if init_futu_connection():
|
|
1479
1835
|
logger.info("Successfully initialized Futu connection")
|
|
1480
|
-
logger.info("Starting MCP server in stdio mode
|
|
1481
|
-
|
|
1836
|
+
logger.info("Starting MCP server in stdio mode - stdout reserved for JSON communication")
|
|
1837
|
+
|
|
1482
1838
|
try:
|
|
1839
|
+
# Run MCP server - stdout will be used for JSON communication only
|
|
1483
1840
|
mcp.run(transport='stdio')
|
|
1484
1841
|
except KeyboardInterrupt:
|
|
1485
|
-
logger.info("Received keyboard interrupt, shutting down...")
|
|
1842
|
+
logger.info("Received keyboard interrupt, shutting down gracefully...")
|
|
1486
1843
|
cleanup_all()
|
|
1487
1844
|
os._exit(0)
|
|
1488
1845
|
except Exception as e:
|
|
1489
|
-
logger.error(f"Error running server: {str(e)}")
|
|
1846
|
+
logger.error(f"Error running MCP server: {str(e)}")
|
|
1490
1847
|
cleanup_all()
|
|
1491
1848
|
os._exit(1)
|
|
1492
1849
|
else:
|
|
1493
|
-
logger.error("Failed to initialize Futu connection.
|
|
1850
|
+
logger.error("Failed to initialize Futu connection. MCP server will not start.")
|
|
1494
1851
|
os._exit(1)
|
|
1852
|
+
|
|
1495
1853
|
except Exception as e:
|
|
1496
|
-
|
|
1854
|
+
# In MCP mode, we should avoid printing to stdout
|
|
1855
|
+
# Log to file only
|
|
1856
|
+
logger.error(f"Error starting MCP server: {str(e)}")
|
|
1497
1857
|
sys.exit(1)
|
|
1498
1858
|
finally:
|
|
1499
1859
|
cleanup_all()
|
|
1500
1860
|
|
|
1501
1861
|
if __name__ == "__main__":
|
|
1502
1862
|
main()
|
|
1863
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: futu-stock-mcp-server
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: A Model Context Protocol (MCP) server for accessing Futu OpenAPI functionality
|
|
5
5
|
Project-URL: Homepage, https://github.com/shuizhengqi1/futu-stock-mcp-server
|
|
6
6
|
Project-URL: Documentation, https://github.com/shuizhengqi1/futu-stock-mcp-server#readme
|
|
@@ -40,7 +40,6 @@ Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
|
40
40
|
Requires-Dist: ruff; extra == 'dev'
|
|
41
41
|
Description-Content-Type: text/markdown
|
|
42
42
|
|
|
43
|
-
[](https://mseep.ai/app/shuizhengqi1-futu-stocp-mcp-server)
|
|
44
43
|
|
|
45
44
|
# Futu Stock MCP Server
|
|
46
45
|
|
|
@@ -399,13 +398,29 @@ python -m futu_stock_mcp_server.server
|
|
|
399
398
|
```
|
|
400
399
|
|
|
401
400
|
### 日志调试
|
|
402
|
-
|
|
401
|
+
|
|
402
|
+
本项目已根据 [MCP 官方文档](https://github.com/modelcontextprotocol/python-sdk) 的最佳实践配置了日志系统:
|
|
403
|
+
|
|
404
|
+
#### MCP 兼容的日志配置
|
|
405
|
+
- **文件日志**: 所有日志写入 `logs/futu_server.log`,自动轮转和清理
|
|
406
|
+
- **MCP Context 日志**: 工具执行期间通过 MCP Context 发送日志给客户端
|
|
407
|
+
- **stdout 保护**: 确保 stdout 仅用于 MCP JSON 通信,避免污染
|
|
408
|
+
|
|
409
|
+
#### 调试模式(仅开发时使用)
|
|
403
410
|
```bash
|
|
404
|
-
|
|
411
|
+
# 启用调试模式(会向 stderr 输出日志)
|
|
412
|
+
export FUTU_DEBUG_MODE=1
|
|
405
413
|
futu-mcp-server
|
|
406
414
|
```
|
|
407
415
|
|
|
408
|
-
|
|
416
|
+
**注意**: 在 MCP 客户端中不要启用调试模式,因为它会向 stderr 输出日志。
|
|
417
|
+
|
|
418
|
+
#### 日志文件位置
|
|
419
|
+
- 主日志文件:`./logs/futu_server.log`
|
|
420
|
+
- 自动轮转:500 MB 后轮转
|
|
421
|
+
- 自动清理:保留 10 天
|
|
422
|
+
|
|
423
|
+
详细的日志配置说明请参考 [docs/LOGGING.md](docs/LOGGING.md)。
|
|
409
424
|
tools = await session.list_tools()
|
|
410
425
|
|
|
411
426
|
# Call a tool
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
futu_stock_mcp_server/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
futu_stock_mcp_server/server.py,sha256=SokGZ3seYN1KwNozJqpbAmRsjcoYVSJ8v_wvMHu3pcs,66611
|
|
3
|
+
futu_stock_mcp_server-0.1.4.dist-info/METADATA,sha256=V8KxUH0ehGGfS7FtOGMH6t-kk1NYkKHhFLi3Vg84FO4,21041
|
|
4
|
+
futu_stock_mcp_server-0.1.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
5
|
+
futu_stock_mcp_server-0.1.4.dist-info/entry_points.txt,sha256=GAdKqPJD9dJ_fRA3e3m0NRia0elN5OcjEeAI30vOcIM,70
|
|
6
|
+
futu_stock_mcp_server-0.1.4.dist-info/licenses/LICENSE,sha256=XQBSQkjjpkymu_uLdyis4oNynV60VH1X7nS16uwM6g0,1069
|
|
7
|
+
futu_stock_mcp_server-0.1.4.dist-info/RECORD,,
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
futu_stock_mcp_server/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
futu_stock_mcp_server/server.py,sha256=xFtYbURXhj0DOkbCFgwHDKHCAATWlSxdBZudrWsodVc,52810
|
|
3
|
-
futu_stock_mcp_server-0.1.2.dist-info/METADATA,sha256=CmSaX6UUSExUdbci62zSZER2udDlV6Fqfqd3SSYHSlg,20453
|
|
4
|
-
futu_stock_mcp_server-0.1.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
5
|
-
futu_stock_mcp_server-0.1.2.dist-info/entry_points.txt,sha256=GAdKqPJD9dJ_fRA3e3m0NRia0elN5OcjEeAI30vOcIM,70
|
|
6
|
-
futu_stock_mcp_server-0.1.2.dist-info/licenses/LICENSE,sha256=XQBSQkjjpkymu_uLdyis4oNynV60VH1X7nS16uwM6g0,1069
|
|
7
|
-
futu_stock_mcp_server-0.1.2.dist-info/RECORD,,
|
|
File without changes
|
{futu_stock_mcp_server-0.1.2.dist-info → futu_stock_mcp_server-0.1.4.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{futu_stock_mcp_server-0.1.2.dist-info → futu_stock_mcp_server-0.1.4.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|