futu-stock-mcp-server 0.1.2__tar.gz → 0.1.3__tar.gz
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-0.1.2 → futu_stock_mcp_server-0.1.3}/PKG-INFO +20 -4
- {futu_stock_mcp_server-0.1.2 → futu_stock_mcp_server-0.1.3}/README.md +19 -3
- {futu_stock_mcp_server-0.1.2 → futu_stock_mcp_server-0.1.3}/pyproject.toml +1 -1
- {futu_stock_mcp_server-0.1.2 → futu_stock_mcp_server-0.1.3}/src/futu_stock_mcp_server/server.py +163 -47
- {futu_stock_mcp_server-0.1.2 → futu_stock_mcp_server-0.1.3}/.gitignore +0 -0
- {futu_stock_mcp_server-0.1.2 → futu_stock_mcp_server-0.1.3}/LICENSE +0 -0
- {futu_stock_mcp_server-0.1.2 → futu_stock_mcp_server-0.1.3}/src/futu_stock_mcp_server/__init__.py +0 -0
|
@@ -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.3
|
|
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
|
|
@@ -399,13 +399,29 @@ python -m futu_stock_mcp_server.server
|
|
|
399
399
|
```
|
|
400
400
|
|
|
401
401
|
### 日志调试
|
|
402
|
-
|
|
402
|
+
|
|
403
|
+
本项目已根据 [MCP 官方文档](https://github.com/modelcontextprotocol/python-sdk) 的最佳实践配置了日志系统:
|
|
404
|
+
|
|
405
|
+
#### MCP 兼容的日志配置
|
|
406
|
+
- **文件日志**: 所有日志写入 `logs/futu_server.log`,自动轮转和清理
|
|
407
|
+
- **MCP Context 日志**: 工具执行期间通过 MCP Context 发送日志给客户端
|
|
408
|
+
- **stdout 保护**: 确保 stdout 仅用于 MCP JSON 通信,避免污染
|
|
409
|
+
|
|
410
|
+
#### 调试模式(仅开发时使用)
|
|
403
411
|
```bash
|
|
404
|
-
|
|
412
|
+
# 启用调试模式(会向 stderr 输出日志)
|
|
413
|
+
export FUTU_DEBUG_MODE=1
|
|
405
414
|
futu-mcp-server
|
|
406
415
|
```
|
|
407
416
|
|
|
408
|
-
|
|
417
|
+
**注意**: 在 MCP 客户端中不要启用调试模式,因为它会向 stderr 输出日志。
|
|
418
|
+
|
|
419
|
+
#### 日志文件位置
|
|
420
|
+
- 主日志文件:`./logs/futu_server.log`
|
|
421
|
+
- 自动轮转:500 MB 后轮转
|
|
422
|
+
- 自动清理:保留 10 天
|
|
423
|
+
|
|
424
|
+
详细的日志配置说明请参考 [docs/LOGGING.md](docs/LOGGING.md)。
|
|
409
425
|
tools = await session.list_tools()
|
|
410
426
|
|
|
411
427
|
# Call a tool
|
|
@@ -357,13 +357,29 @@ python -m futu_stock_mcp_server.server
|
|
|
357
357
|
```
|
|
358
358
|
|
|
359
359
|
### 日志调试
|
|
360
|
-
|
|
360
|
+
|
|
361
|
+
本项目已根据 [MCP 官方文档](https://github.com/modelcontextprotocol/python-sdk) 的最佳实践配置了日志系统:
|
|
362
|
+
|
|
363
|
+
#### MCP 兼容的日志配置
|
|
364
|
+
- **文件日志**: 所有日志写入 `logs/futu_server.log`,自动轮转和清理
|
|
365
|
+
- **MCP Context 日志**: 工具执行期间通过 MCP Context 发送日志给客户端
|
|
366
|
+
- **stdout 保护**: 确保 stdout 仅用于 MCP JSON 通信,避免污染
|
|
367
|
+
|
|
368
|
+
#### 调试模式(仅开发时使用)
|
|
361
369
|
```bash
|
|
362
|
-
|
|
370
|
+
# 启用调试模式(会向 stderr 输出日志)
|
|
371
|
+
export FUTU_DEBUG_MODE=1
|
|
363
372
|
futu-mcp-server
|
|
364
373
|
```
|
|
365
374
|
|
|
366
|
-
|
|
375
|
+
**注意**: 在 MCP 客户端中不要启用调试模式,因为它会向 stderr 输出日志。
|
|
376
|
+
|
|
377
|
+
#### 日志文件位置
|
|
378
|
+
- 主日志文件:`./logs/futu_server.log`
|
|
379
|
+
- 自动轮转:500 MB 后轮转
|
|
380
|
+
- 自动清理:保留 10 天
|
|
381
|
+
|
|
382
|
+
详细的日志配置说明请参考 [docs/LOGGING.md](docs/LOGGING.md)。
|
|
367
383
|
tools = await session.list_tools()
|
|
368
384
|
|
|
369
385
|
# Call a tool
|
{futu_stock_mcp_server-0.1.2 → futu_stock_mcp_server-0.1.3}/src/futu_stock_mcp_server/server.py
RENAMED
|
@@ -8,15 +8,18 @@ from loguru import logger
|
|
|
8
8
|
import os
|
|
9
9
|
import sys
|
|
10
10
|
from dotenv import load_dotenv
|
|
11
|
-
from mcp.server.fastmcp import FastMCP
|
|
11
|
+
from mcp.server.fastmcp import FastMCP, Context
|
|
12
12
|
from mcp.types import TextContent, PromptMessage
|
|
13
13
|
from mcp.server import Server
|
|
14
|
+
from mcp.server.session import ServerSession
|
|
14
15
|
import atexit
|
|
15
16
|
import signal
|
|
16
17
|
import fcntl
|
|
17
18
|
import psutil
|
|
18
19
|
import time
|
|
19
20
|
from datetime import datetime
|
|
21
|
+
import logging
|
|
22
|
+
import warnings
|
|
20
23
|
|
|
21
24
|
# Get the project root directory and add it to Python path
|
|
22
25
|
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
@@ -26,37 +29,104 @@ sys.path.insert(0, project_root)
|
|
|
26
29
|
env_path = os.path.join(project_root, '.env')
|
|
27
30
|
load_dotenv(env_path)
|
|
28
31
|
|
|
29
|
-
# Configure logging
|
|
30
|
-
|
|
32
|
+
# CRITICAL: Configure logging to be MCP-compatible
|
|
33
|
+
# According to MCP best practices, we should:
|
|
34
|
+
# 1. Never write to stdout (reserved for MCP JSON communication)
|
|
35
|
+
# 2. Use file logging for debugging
|
|
36
|
+
# 3. Use MCP Context for operational logging when available
|
|
37
|
+
# 4. Suppress third-party library logs that might pollute output
|
|
38
|
+
|
|
39
|
+
# Completely silence warnings and third-party logs
|
|
40
|
+
warnings.filterwarnings("ignore")
|
|
41
|
+
|
|
42
|
+
# Configure loguru for file-only logging
|
|
43
|
+
logger.remove() # Remove all default handlers
|
|
31
44
|
|
|
32
45
|
# Get the project root directory
|
|
33
46
|
log_dir = os.path.join(project_root, "logs")
|
|
34
47
|
os.makedirs(log_dir, exist_ok=True)
|
|
35
48
|
|
|
36
|
-
# Add file handler
|
|
49
|
+
# Add file handler only - NO console output to avoid MCP communication interference
|
|
37
50
|
logger.add(
|
|
38
51
|
os.path.join(log_dir, "futu_server.log"),
|
|
39
52
|
rotation="500 MB",
|
|
40
53
|
retention="10 days",
|
|
41
54
|
level="DEBUG",
|
|
42
|
-
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}"
|
|
55
|
+
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {message}",
|
|
56
|
+
enqueue=True, # Thread-safe logging
|
|
57
|
+
backtrace=True,
|
|
58
|
+
diagnose=True
|
|
43
59
|
)
|
|
44
60
|
|
|
45
|
-
#
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
# Only add stderr logging if explicitly in debug mode and not in MCP mode
|
|
62
|
+
if os.getenv('FUTU_DEBUG_MODE') == '1' and not os.getenv('MCP_MODE'):
|
|
63
|
+
logger.add(
|
|
64
|
+
sys.stderr,
|
|
65
|
+
level="INFO",
|
|
66
|
+
format="{time:HH:mm:ss} | {level} | {message}",
|
|
67
|
+
colorize=False,
|
|
68
|
+
filter=lambda record: record["level"].name in ["INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
69
|
+
)
|
|
52
70
|
|
|
53
|
-
# Suppress
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
71
|
+
# Suppress all third-party library logging to prevent stdout pollution
|
|
72
|
+
logging.disable(logging.CRITICAL)
|
|
73
|
+
|
|
74
|
+
# Set up null handlers for problematic loggers
|
|
75
|
+
class NullHandler(logging.Handler):
|
|
76
|
+
def emit(self, record):
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
null_handler = NullHandler()
|
|
80
|
+
root_logger = logging.getLogger()
|
|
81
|
+
root_logger.addHandler(null_handler)
|
|
82
|
+
root_logger.setLevel(logging.CRITICAL + 1)
|
|
83
|
+
|
|
84
|
+
# Specifically silence known problematic loggers
|
|
85
|
+
for logger_name in [
|
|
86
|
+
'mcp', 'fastmcp', 'futu', 'uvicorn', 'asyncio',
|
|
87
|
+
'websockets', 'aiohttp', 'urllib3', 'requests'
|
|
88
|
+
]:
|
|
89
|
+
lib_logger = logging.getLogger(logger_name)
|
|
90
|
+
lib_logger.disabled = True
|
|
91
|
+
lib_logger.addHandler(null_handler)
|
|
92
|
+
lib_logger.setLevel(logging.CRITICAL + 1)
|
|
93
|
+
lib_logger.propagate = False
|
|
94
|
+
|
|
95
|
+
# MCP-compatible logging helper functions
|
|
96
|
+
async def log_to_mcp(ctx: Context, level: str, message: str):
|
|
97
|
+
"""Send log message through MCP Context when available"""
|
|
98
|
+
try:
|
|
99
|
+
if level.upper() == "DEBUG":
|
|
100
|
+
await ctx.debug(message)
|
|
101
|
+
elif level.upper() == "INFO":
|
|
102
|
+
await ctx.info(message)
|
|
103
|
+
elif level.upper() == "WARNING":
|
|
104
|
+
await ctx.warning(message)
|
|
105
|
+
elif level.upper() == "ERROR":
|
|
106
|
+
await ctx.error(message)
|
|
107
|
+
else:
|
|
108
|
+
await ctx.info(f"[{level}] {message}")
|
|
109
|
+
except Exception:
|
|
110
|
+
# Fallback to file logging if MCP context fails
|
|
111
|
+
logger.log(level.upper(), message)
|
|
58
112
|
|
|
59
|
-
|
|
113
|
+
def safe_log(level: str, message: str, ctx: Context = None):
|
|
114
|
+
"""Safe logging that uses MCP context when available, file logging otherwise"""
|
|
115
|
+
# Always log to file
|
|
116
|
+
logger.log(level.upper(), message)
|
|
117
|
+
|
|
118
|
+
# Also send to MCP if context is available
|
|
119
|
+
if ctx:
|
|
120
|
+
try:
|
|
121
|
+
import asyncio
|
|
122
|
+
loop = asyncio.get_event_loop()
|
|
123
|
+
if loop.is_running():
|
|
124
|
+
asyncio.create_task(log_to_mcp(ctx, level, message))
|
|
125
|
+
except Exception:
|
|
126
|
+
pass # Ignore MCP logging errors
|
|
127
|
+
|
|
128
|
+
logger.info(f"Starting Futu MCP Server with log directory: {log_dir}")
|
|
129
|
+
logger.info("Logging configured for MCP compatibility - stdout reserved for JSON communication")
|
|
60
130
|
|
|
61
131
|
# PID file path
|
|
62
132
|
PID_FILE = os.path.join(project_root, '.futu_mcp.pid')
|
|
@@ -411,7 +481,7 @@ def handle_return_data(ret: int, data: Any) -> Dict[str, Any]:
|
|
|
411
481
|
|
|
412
482
|
# Market Data Tools
|
|
413
483
|
@mcp.tool()
|
|
414
|
-
async def get_stock_quote(symbols: List[str]) -> Dict[str, Any]:
|
|
484
|
+
async def get_stock_quote(symbols: List[str], ctx: Context[ServerSession, None] = None) -> Dict[str, Any]:
|
|
415
485
|
"""Get stock quote data for given symbols
|
|
416
486
|
|
|
417
487
|
Args:
|
|
@@ -455,21 +525,32 @@ async def get_stock_quote(symbols: List[str]) -> Dict[str, Any]:
|
|
|
455
525
|
- Consider actual needs when selecting stocks
|
|
456
526
|
- Handle exceptions properly
|
|
457
527
|
"""
|
|
458
|
-
|
|
459
|
-
if ret != RET_OK:
|
|
460
|
-
return {'error': str(data)}
|
|
528
|
+
safe_log("info", f"Getting stock quotes for symbols: {symbols}", ctx)
|
|
461
529
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
result = {
|
|
469
|
-
'quote_list': data
|
|
470
|
-
}
|
|
530
|
+
try:
|
|
531
|
+
ret, data = quote_ctx.get_stock_quote(symbols)
|
|
532
|
+
if ret != RET_OK:
|
|
533
|
+
error_msg = f"Failed to get stock quote: {str(data)}"
|
|
534
|
+
safe_log("error", error_msg, ctx)
|
|
535
|
+
return {'error': error_msg}
|
|
471
536
|
|
|
472
|
-
|
|
537
|
+
# Convert DataFrame to dict if necessary
|
|
538
|
+
if hasattr(data, 'to_dict'):
|
|
539
|
+
result = {
|
|
540
|
+
'quote_list': data.to_dict('records')
|
|
541
|
+
}
|
|
542
|
+
else:
|
|
543
|
+
result = {
|
|
544
|
+
'quote_list': data
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
safe_log("info", f"Successfully retrieved quotes for {len(symbols)} symbols", ctx)
|
|
548
|
+
return result
|
|
549
|
+
|
|
550
|
+
except Exception as e:
|
|
551
|
+
error_msg = f"Exception in get_stock_quote: {str(e)}"
|
|
552
|
+
safe_log("error", error_msg, ctx)
|
|
553
|
+
return {'error': error_msg}
|
|
473
554
|
|
|
474
555
|
@mcp.tool()
|
|
475
556
|
async def get_market_snapshot(symbols: List[str]) -> Dict[str, Any]:
|
|
@@ -1126,12 +1207,29 @@ async def get_option_butterfly(symbol: str, expiry: str, strike_price: float) ->
|
|
|
1126
1207
|
|
|
1127
1208
|
# Account Query Tools
|
|
1128
1209
|
@mcp.tool()
|
|
1129
|
-
async def get_account_list() -> Dict[str, Any]:
|
|
1210
|
+
async def get_account_list(ctx: Context[ServerSession, None] = None) -> Dict[str, Any]:
|
|
1130
1211
|
"""Get account list"""
|
|
1212
|
+
safe_log("info", "Attempting to get account list", ctx)
|
|
1213
|
+
|
|
1131
1214
|
if not init_trade_connection():
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1215
|
+
error_msg = 'Failed to initialize trade connection'
|
|
1216
|
+
safe_log("error", error_msg, ctx)
|
|
1217
|
+
return {'error': error_msg}
|
|
1218
|
+
|
|
1219
|
+
try:
|
|
1220
|
+
ret, data = trade_ctx.get_acc_list()
|
|
1221
|
+
result = handle_return_data(ret, data)
|
|
1222
|
+
|
|
1223
|
+
if 'error' not in result:
|
|
1224
|
+
safe_log("info", "Successfully retrieved account list", ctx)
|
|
1225
|
+
else:
|
|
1226
|
+
safe_log("error", f"Failed to get account list: {result['error']}", ctx)
|
|
1227
|
+
|
|
1228
|
+
return result
|
|
1229
|
+
except Exception as e:
|
|
1230
|
+
error_msg = f"Exception in get_account_list: {str(e)}"
|
|
1231
|
+
safe_log("error", error_msg, ctx)
|
|
1232
|
+
return {'error': error_msg}
|
|
1135
1233
|
|
|
1136
1234
|
@mcp.tool()
|
|
1137
1235
|
async def get_funds() -> Dict[str, Any]:
|
|
@@ -1457,43 +1555,61 @@ async def get_current_time() -> Dict[str, Any]:
|
|
|
1457
1555
|
def main():
|
|
1458
1556
|
"""Main entry point for the futu-mcp-server command."""
|
|
1459
1557
|
try:
|
|
1460
|
-
#
|
|
1558
|
+
# CRITICAL: Set MCP mode BEFORE any logging to ensure clean stdout
|
|
1559
|
+
os.environ['MCP_MODE'] = '1'
|
|
1560
|
+
|
|
1561
|
+
# Ensure no color output or ANSI escape sequences in MCP mode
|
|
1461
1562
|
os.environ['NO_COLOR'] = '1'
|
|
1462
1563
|
os.environ['TERM'] = 'dumb'
|
|
1564
|
+
os.environ['FORCE_COLOR'] = '0'
|
|
1565
|
+
os.environ['COLORTERM'] = ''
|
|
1566
|
+
os.environ['ANSI_COLORS_DISABLED'] = '1'
|
|
1567
|
+
|
|
1568
|
+
# Disable Python buffering to ensure clean MCP JSON communication
|
|
1569
|
+
os.environ['PYTHONUNBUFFERED'] = '1'
|
|
1570
|
+
os.environ['PYTHONIOENCODING'] = 'utf-8'
|
|
1463
1571
|
|
|
1464
|
-
#
|
|
1572
|
+
# According to MCP best practices:
|
|
1573
|
+
# - stdout is RESERVED for MCP JSON communication only
|
|
1574
|
+
# - All logging should go to files
|
|
1575
|
+
# - No stderr output in production mode to avoid pollution
|
|
1576
|
+
|
|
1577
|
+
# Clean up stale processes and acquire lock
|
|
1465
1578
|
cleanup_stale_processes()
|
|
1466
1579
|
|
|
1467
|
-
# 获取锁
|
|
1468
1580
|
lock_fd = acquire_lock()
|
|
1469
1581
|
if lock_fd is None:
|
|
1582
|
+
# Use file logging only - no stderr output in MCP mode
|
|
1470
1583
|
logger.error("Failed to acquire lock. Another instance may be running.")
|
|
1471
1584
|
sys.exit(1)
|
|
1472
1585
|
|
|
1473
|
-
#
|
|
1586
|
+
# Set up signal handlers
|
|
1474
1587
|
signal.signal(signal.SIGINT, signal_handler)
|
|
1475
1588
|
signal.signal(signal.SIGTERM, signal_handler)
|
|
1476
1589
|
|
|
1477
|
-
|
|
1590
|
+
# Initialize Futu connection with file logging only
|
|
1591
|
+
logger.info("Initializing Futu connection for MCP server...")
|
|
1478
1592
|
if init_futu_connection():
|
|
1479
1593
|
logger.info("Successfully initialized Futu connection")
|
|
1480
|
-
logger.info("Starting MCP server in stdio mode
|
|
1481
|
-
|
|
1594
|
+
logger.info("Starting MCP server in stdio mode - stdout reserved for JSON communication")
|
|
1595
|
+
|
|
1482
1596
|
try:
|
|
1597
|
+
# Run MCP server - stdout will be used for JSON communication only
|
|
1483
1598
|
mcp.run(transport='stdio')
|
|
1484
1599
|
except KeyboardInterrupt:
|
|
1485
|
-
logger.info("Received keyboard interrupt, shutting down...")
|
|
1600
|
+
logger.info("Received keyboard interrupt, shutting down gracefully...")
|
|
1486
1601
|
cleanup_all()
|
|
1487
1602
|
os._exit(0)
|
|
1488
1603
|
except Exception as e:
|
|
1489
|
-
logger.error(f"Error running server: {str(e)}")
|
|
1604
|
+
logger.error(f"Error running MCP server: {str(e)}")
|
|
1490
1605
|
cleanup_all()
|
|
1491
1606
|
os._exit(1)
|
|
1492
1607
|
else:
|
|
1493
|
-
logger.error("Failed to initialize Futu connection.
|
|
1608
|
+
logger.error("Failed to initialize Futu connection. MCP server will not start.")
|
|
1494
1609
|
os._exit(1)
|
|
1610
|
+
|
|
1495
1611
|
except Exception as e:
|
|
1496
|
-
logger.error(f"Error starting server: {str(e)}")
|
|
1612
|
+
logger.error(f"Error starting MCP server: {str(e)}")
|
|
1497
1613
|
sys.exit(1)
|
|
1498
1614
|
finally:
|
|
1499
1615
|
cleanup_all()
|
|
File without changes
|
|
File without changes
|
{futu_stock_mcp_server-0.1.2 → futu_stock_mcp_server-0.1.3}/src/futu_stock_mcp_server/__init__.py
RENAMED
|
File without changes
|