webull-openapi-mcp 0.1.0__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.
- webull_openapi_mcp/__init__.py +3 -0
- webull_openapi_mcp/__main__.py +6 -0
- webull_openapi_mcp/audit.py +161 -0
- webull_openapi_mcp/cli.py +451 -0
- webull_openapi_mcp/config.py +164 -0
- webull_openapi_mcp/constants.py +94 -0
- webull_openapi_mcp/errors.py +160 -0
- webull_openapi_mcp/formatters.py +1059 -0
- webull_openapi_mcp/guards.py +539 -0
- webull_openapi_mcp/region_config.py +124 -0
- webull_openapi_mcp/sdk_client.py +251 -0
- webull_openapi_mcp/server.py +146 -0
- webull_openapi_mcp/tools/__init__.py +41 -0
- webull_openapi_mcp/tools/market_data/__init__.py +20 -0
- webull_openapi_mcp/tools/market_data/crypto.py +83 -0
- webull_openapi_mcp/tools/market_data/event.py +139 -0
- webull_openapi_mcp/tools/market_data/futures.py +172 -0
- webull_openapi_mcp/tools/market_data/stock.py +209 -0
- webull_openapi_mcp/tools/trading/__init__.py +56 -0
- webull_openapi_mcp/tools/trading/account.py +129 -0
- webull_openapi_mcp/tools/trading/assets.py +65 -0
- webull_openapi_mcp/tools/trading/crypto_order.py +171 -0
- webull_openapi_mcp/tools/trading/event_order.py +177 -0
- webull_openapi_mcp/tools/trading/futures_order.py +198 -0
- webull_openapi_mcp/tools/trading/instrument.py +282 -0
- webull_openapi_mcp/tools/trading/option_order.py +467 -0
- webull_openapi_mcp/tools/trading/order.py +158 -0
- webull_openapi_mcp/tools/trading/stock_order.py +591 -0
- webull_openapi_mcp-0.1.0.dist-info/METADATA +356 -0
- webull_openapi_mcp-0.1.0.dist-info/RECORD +33 -0
- webull_openapi_mcp-0.1.0.dist-info/WHEEL +4 -0
- webull_openapi_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- webull_openapi_mcp-0.1.0.dist-info/licenses/LICENSE +191 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Audit logging for Webull MCP Server.
|
|
2
|
+
|
|
3
|
+
All audit events are emitted as single-line JSON to stderr (always) and
|
|
4
|
+
optionally to a rotating log file when ``WEBULL_AUDIT_LOG_FILE`` is configured.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import sys
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from logging.handlers import RotatingFileHandler
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from webull_openapi_mcp.config import ServerConfig
|
|
18
|
+
|
|
19
|
+
# Fields whose *values* must never appear in any log output.
|
|
20
|
+
_CREDENTIAL_KEYS = frozenset({"app_key", "app_secret", "access_token"})
|
|
21
|
+
|
|
22
|
+
# Fields that are replaced with "***" in TOOL_CALL params.
|
|
23
|
+
_PRICE_SENSITIVE_KEYS = frozenset({"price", "stop_price"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _JsonFormatter(logging.Formatter):
|
|
27
|
+
"""Emit each log record as a single JSON line."""
|
|
28
|
+
|
|
29
|
+
def format(self, record: logging.LogRecord) -> str: # noqa: A003
|
|
30
|
+
return record.getMessage()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AuditLogger:
|
|
34
|
+
"""Structured audit logger for tool calls, order events and validation errors."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, config: ServerConfig) -> None:
|
|
37
|
+
self._logger = logging.getLogger("webull_openapi_mcp.audit")
|
|
38
|
+
self._logger.setLevel(logging.INFO)
|
|
39
|
+
self._logger.propagate = False
|
|
40
|
+
|
|
41
|
+
# Remove any pre-existing handlers (important for tests / re-init).
|
|
42
|
+
self._logger.handlers.clear()
|
|
43
|
+
|
|
44
|
+
formatter = _JsonFormatter()
|
|
45
|
+
|
|
46
|
+
# stderr handler — always enabled
|
|
47
|
+
stderr_handler = logging.StreamHandler(sys.stderr)
|
|
48
|
+
stderr_handler.setFormatter(formatter)
|
|
49
|
+
self._logger.addHandler(stderr_handler)
|
|
50
|
+
|
|
51
|
+
# Optional rotating file handler
|
|
52
|
+
if config.audit_log_file:
|
|
53
|
+
file_handler = RotatingFileHandler(
|
|
54
|
+
config.audit_log_file,
|
|
55
|
+
maxBytes=10 * 1024 * 1024, # 10 MB
|
|
56
|
+
backupCount=5,
|
|
57
|
+
encoding="utf-8",
|
|
58
|
+
)
|
|
59
|
+
file_handler.setFormatter(formatter)
|
|
60
|
+
self._logger.addHandler(file_handler)
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
# Public API
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def log_tool_call(self, tool_name: str, params: dict[str, Any]) -> None:
|
|
67
|
+
"""Record a ``TOOL_CALL`` event with sanitised parameters."""
|
|
68
|
+
sanitised = self._sanitize_params(params)
|
|
69
|
+
self._emit({"event": "TOOL_CALL", "tool": tool_name, "params": sanitised})
|
|
70
|
+
|
|
71
|
+
def log_order_attempt(
|
|
72
|
+
self,
|
|
73
|
+
symbol: str,
|
|
74
|
+
side: str,
|
|
75
|
+
quantity: float,
|
|
76
|
+
order_type: str,
|
|
77
|
+
client_order_id: str,
|
|
78
|
+
account_id: str,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Record an ``ORDER_ATTEMPT`` event."""
|
|
81
|
+
self._emit(
|
|
82
|
+
{
|
|
83
|
+
"event": "ORDER_ATTEMPT",
|
|
84
|
+
"symbol": symbol,
|
|
85
|
+
"side": side,
|
|
86
|
+
"quantity": quantity,
|
|
87
|
+
"order_type": order_type,
|
|
88
|
+
"client_order_id": client_order_id,
|
|
89
|
+
"account_id": account_id,
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def log_order_result(
|
|
94
|
+
self, client_order_id: str, success: bool, response: dict[str, Any]
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Record an ``ORDER_RESULT`` event."""
|
|
97
|
+
safe_response = self._strip_credentials(response)
|
|
98
|
+
self._emit(
|
|
99
|
+
{
|
|
100
|
+
"event": "ORDER_RESULT",
|
|
101
|
+
"client_order_id": client_order_id,
|
|
102
|
+
"success": success,
|
|
103
|
+
"response": safe_response,
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def log_validation_error(
|
|
108
|
+
self, tool_name: str, error: str, params: dict[str, Any]
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Record a ``VALIDATION_ERROR`` event."""
|
|
111
|
+
sanitised = self._sanitize_params(params)
|
|
112
|
+
self._emit(
|
|
113
|
+
{
|
|
114
|
+
"event": "VALIDATION_ERROR",
|
|
115
|
+
"tool": tool_name,
|
|
116
|
+
"error": error,
|
|
117
|
+
"params": sanitised,
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
# Internal helpers
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def _emit(self, payload: dict[str, Any]) -> None:
|
|
126
|
+
"""Add timestamp and write a single JSON line via the logger."""
|
|
127
|
+
payload["timestamp"] = datetime.now(timezone.utc).isoformat()
|
|
128
|
+
self._logger.info(json.dumps(payload, default=str))
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _sanitize_params(params: dict[str, Any]) -> dict[str, Any]:
|
|
132
|
+
"""Return a copy with price fields masked and credential keys removed."""
|
|
133
|
+
result: dict[str, Any] = {}
|
|
134
|
+
for key, value in params.items():
|
|
135
|
+
if key in _CREDENTIAL_KEYS:
|
|
136
|
+
continue
|
|
137
|
+
if key in _PRICE_SENSITIVE_KEYS:
|
|
138
|
+
result[key] = "***"
|
|
139
|
+
else:
|
|
140
|
+
result[key] = value
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def _strip_credentials(data: dict[str, Any]) -> dict[str, Any]:
|
|
145
|
+
"""Return a deep copy with all credential keys removed recursively."""
|
|
146
|
+
cleaned = copy.deepcopy(data)
|
|
147
|
+
AuditLogger._remove_keys_recursive(cleaned, _CREDENTIAL_KEYS)
|
|
148
|
+
return cleaned
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _remove_keys_recursive(obj: Any, keys: frozenset[str]) -> None:
|
|
152
|
+
"""Recursively remove *keys* from nested dicts/lists in-place."""
|
|
153
|
+
if isinstance(obj, dict):
|
|
154
|
+
for k in list(obj.keys()):
|
|
155
|
+
if k in keys:
|
|
156
|
+
del obj[k]
|
|
157
|
+
else:
|
|
158
|
+
AuditLogger._remove_keys_recursive(obj[k], keys)
|
|
159
|
+
elif isinstance(obj, list):
|
|
160
|
+
for item in obj:
|
|
161
|
+
AuditLogger._remove_keys_recursive(item, keys)
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""Command-line interface for Webull OpenAPI MCP Server.
|
|
2
|
+
|
|
3
|
+
Provides commands for initialization, server startup, status checking,
|
|
4
|
+
and tool listing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import stat
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
from webull_openapi_mcp import __version__
|
|
17
|
+
from webull_openapi_mcp.config import ServerConfig, load_config, validate_config
|
|
18
|
+
from webull_openapi_mcp.errors import ConfigError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _mask_secret(value: str, visible_chars: int = 4) -> str:
|
|
22
|
+
"""Mask a secret value, showing only the first few characters."""
|
|
23
|
+
if not value:
|
|
24
|
+
return "(not set)"
|
|
25
|
+
if len(value) <= visible_chars:
|
|
26
|
+
return "*" * len(value)
|
|
27
|
+
return value[:visible_chars] + "*" * (len(value) - visible_chars)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_posix() -> bool:
|
|
31
|
+
"""Check if running on a POSIX system."""
|
|
32
|
+
return os.name == "posix"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Common options
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
_env_file_option = click.option(
|
|
40
|
+
"--env-file",
|
|
41
|
+
type=click.Path(exists=False),
|
|
42
|
+
default=None,
|
|
43
|
+
help="Path to .env file (default: .env in current directory)",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# CLI group
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
@click.group(invoke_without_command=True)
|
|
52
|
+
@click.option("--version", is_flag=True, help="Show version and exit")
|
|
53
|
+
@click.pass_context
|
|
54
|
+
def main(ctx: click.Context, version: bool) -> None:
|
|
55
|
+
"""Webull OpenAPI MCP Server - AI assistant integration for Webull OpenAPI."""
|
|
56
|
+
if version:
|
|
57
|
+
click.echo(f"webull-openapi-mcp {__version__}")
|
|
58
|
+
ctx.exit(0)
|
|
59
|
+
|
|
60
|
+
if ctx.invoked_subcommand is None:
|
|
61
|
+
click.echo(ctx.get_help())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# init command — helpers
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def _load_existing_env(path: Path) -> dict[str, str]:
|
|
69
|
+
"""Load key=value pairs from an existing .env file."""
|
|
70
|
+
values: dict[str, str] = {}
|
|
71
|
+
if not path.exists():
|
|
72
|
+
return values
|
|
73
|
+
try:
|
|
74
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
75
|
+
for line in f:
|
|
76
|
+
line = line.strip()
|
|
77
|
+
if line and not line.startswith("#") and "=" in line:
|
|
78
|
+
key, _, value = line.partition("=")
|
|
79
|
+
values[key.strip()] = value.strip()
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
return values
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _prompt_credential(
|
|
86
|
+
value: str | None,
|
|
87
|
+
existing: dict[str, str],
|
|
88
|
+
env_key: str,
|
|
89
|
+
label: str,
|
|
90
|
+
*,
|
|
91
|
+
hidden: bool = False,
|
|
92
|
+
) -> str:
|
|
93
|
+
"""Prompt for a credential if not provided, with existing value fallback."""
|
|
94
|
+
if value is not None:
|
|
95
|
+
return value
|
|
96
|
+
existing_val = existing.get(env_key, "")
|
|
97
|
+
if existing_val:
|
|
98
|
+
click.echo(f"{label} [current: {_mask_secret(existing_val)}]")
|
|
99
|
+
new_val = click.prompt(
|
|
100
|
+
"Enter new value or press Enter to keep current",
|
|
101
|
+
default="",
|
|
102
|
+
hide_input=hidden,
|
|
103
|
+
show_default=False,
|
|
104
|
+
)
|
|
105
|
+
return new_val if new_val else existing_val
|
|
106
|
+
return click.prompt(label, hide_input=hidden)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _build_env_content(app_key: str, app_secret: str, environment: str) -> str:
|
|
110
|
+
"""Build .env file content string."""
|
|
111
|
+
return f"""# Webull MCP Server Configuration
|
|
112
|
+
# Generated by: webull-mcp-server init
|
|
113
|
+
|
|
114
|
+
# Required: Webull API credentials
|
|
115
|
+
WEBULL_APP_KEY={app_key}
|
|
116
|
+
WEBULL_APP_SECRET={app_secret}
|
|
117
|
+
|
|
118
|
+
# API environment: uat or prod (default: prod)
|
|
119
|
+
WEBULL_ENVIRONMENT={environment}
|
|
120
|
+
|
|
121
|
+
# Optional: Region ID (default: us)
|
|
122
|
+
# WEBULL_REGION_ID=us
|
|
123
|
+
|
|
124
|
+
# Optional: Risk control parameters
|
|
125
|
+
# WEBULL_MAX_ORDER_NOTIONAL=10000
|
|
126
|
+
# WEBULL_MAX_ORDER_QUANTITY=1000
|
|
127
|
+
|
|
128
|
+
# Optional: Symbol whitelist (comma-separated, empty = no restriction)
|
|
129
|
+
# WEBULL_SYMBOL_WHITELIST=AAPL,MSFT,GOOGL
|
|
130
|
+
|
|
131
|
+
# Optional: Token storage directory (default: SDK default ./conf/)
|
|
132
|
+
# WEBULL_TOKEN_DIR=
|
|
133
|
+
|
|
134
|
+
# Optional: Audit log file path (default: stderr only)
|
|
135
|
+
# WEBULL_AUDIT_LOG_FILE=
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _write_env_file(
|
|
140
|
+
target_path: Path, content: str, app_key: str, app_secret: str, environment: str,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Write .env file and set permissions."""
|
|
143
|
+
try:
|
|
144
|
+
with open(target_path, "w", encoding="utf-8") as f:
|
|
145
|
+
f.write(content)
|
|
146
|
+
if _is_posix():
|
|
147
|
+
os.chmod(target_path, stat.S_IRUSR | stat.S_IWUSR)
|
|
148
|
+
click.echo(f"Created {target_path} with permissions 0600")
|
|
149
|
+
else:
|
|
150
|
+
click.echo(f"Created {target_path}")
|
|
151
|
+
click.echo(f"\nConfiguration saved:")
|
|
152
|
+
click.echo(f" App Key: {_mask_secret(app_key)}")
|
|
153
|
+
click.echo(f" App Secret: {_mask_secret(app_secret)}")
|
|
154
|
+
click.echo(f" Environment: {environment}")
|
|
155
|
+
click.echo(f"\nRun 'webull-mcp-server serve' to start the MCP server.")
|
|
156
|
+
except Exception as e:
|
|
157
|
+
click.echo(f"Error writing {target_path}: {e}", err=True)
|
|
158
|
+
raise SystemExit(1)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# init command
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
@main.command()
|
|
166
|
+
@click.option("--app-key", default=None, help="Webull App Key")
|
|
167
|
+
@click.option("--app-secret", default=None, help="Webull App Secret")
|
|
168
|
+
@click.option(
|
|
169
|
+
"--environment",
|
|
170
|
+
type=click.Choice(["uat", "prod"]),
|
|
171
|
+
default="prod",
|
|
172
|
+
help="API environment (default: prod)",
|
|
173
|
+
)
|
|
174
|
+
def init(
|
|
175
|
+
app_key: str | None,
|
|
176
|
+
app_secret: str | None,
|
|
177
|
+
environment: str,
|
|
178
|
+
env_file: str | None,
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Initialize configuration by creating or updating .env file.
|
|
181
|
+
|
|
182
|
+
Interactively prompts for credentials if not provided via options.
|
|
183
|
+
On POSIX systems, sets file permissions to 0600 (owner read/write only).
|
|
184
|
+
"""
|
|
185
|
+
target_path = Path(env_file) if env_file else Path(".env")
|
|
186
|
+
existing_values = _load_existing_env(target_path)
|
|
187
|
+
|
|
188
|
+
app_key = _prompt_credential(
|
|
189
|
+
app_key, existing_values, "WEBULL_APP_KEY", "Webull App Key", hidden=False,
|
|
190
|
+
)
|
|
191
|
+
app_secret = _prompt_credential(
|
|
192
|
+
app_secret, existing_values, "WEBULL_APP_SECRET", "Webull App Secret", hidden=True,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
env_content = _build_env_content(app_key, app_secret, environment)
|
|
196
|
+
_write_env_file(target_path, env_content, app_key, app_secret, environment)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
# serve command
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def _check_token_status(config: ServerConfig) -> str | None:
|
|
204
|
+
"""Check local token file status.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
None if token is valid (NORMAL) or doesn't exist
|
|
208
|
+
Error message if token is PENDING (needs 2FA)
|
|
209
|
+
"""
|
|
210
|
+
token_dir = config.token_dir or "./conf"
|
|
211
|
+
token_file = Path(token_dir) / "token.txt"
|
|
212
|
+
|
|
213
|
+
if not token_file.exists():
|
|
214
|
+
return None # No token file, SDK will create one
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
with open(token_file, "r", encoding="utf-8") as f:
|
|
218
|
+
lines = f.readlines()
|
|
219
|
+
if len(lines) >= 3:
|
|
220
|
+
status = lines[2].strip()
|
|
221
|
+
if status == "PENDING":
|
|
222
|
+
return (
|
|
223
|
+
"2FA Authentication Required\n"
|
|
224
|
+
"===========================\n"
|
|
225
|
+
"\n"
|
|
226
|
+
"Your token status is PENDING - waiting for 2FA approval.\n"
|
|
227
|
+
"The MCP server cannot wait for 2FA approval in stdio mode.\n"
|
|
228
|
+
"\n"
|
|
229
|
+
"Please run the following command first:\n"
|
|
230
|
+
" webull-openapi-mcp auth\n"
|
|
231
|
+
"\n"
|
|
232
|
+
"This will allow you to complete 2FA interactively.\n"
|
|
233
|
+
"After authentication succeeds, restart the MCP server.\n"
|
|
234
|
+
)
|
|
235
|
+
except Exception:
|
|
236
|
+
pass # Ignore read errors, let SDK handle it
|
|
237
|
+
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@main.command()
|
|
242
|
+
@_env_file_option
|
|
243
|
+
def serve(env_file: str | None) -> None:
|
|
244
|
+
"""Start the MCP server in stdio mode.
|
|
245
|
+
|
|
246
|
+
Loads configuration from .env file, initializes the Webull SDK,
|
|
247
|
+
and starts the MCP stdio server for AI client communication.
|
|
248
|
+
"""
|
|
249
|
+
from webull_openapi_mcp.sdk_client import (
|
|
250
|
+
DeviceNotRegisteredError,
|
|
251
|
+
TwoFactorAuthRequiredError,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
config = load_config(env_file)
|
|
256
|
+
validate_config(config)
|
|
257
|
+
except ConfigError as e:
|
|
258
|
+
click.echo(f"Configuration error: {e}", err=True)
|
|
259
|
+
raise SystemExit(1)
|
|
260
|
+
|
|
261
|
+
# Check token status before starting - fail fast if 2FA pending
|
|
262
|
+
token_error = _check_token_status(config)
|
|
263
|
+
if token_error:
|
|
264
|
+
click.echo(f"\n{token_error}", err=True)
|
|
265
|
+
raise SystemExit(1)
|
|
266
|
+
|
|
267
|
+
from webull_openapi_mcp.server import build_server
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
server = build_server(config)
|
|
271
|
+
server.run(transport="stdio")
|
|
272
|
+
except KeyboardInterrupt:
|
|
273
|
+
# Graceful shutdown on Ctrl+C
|
|
274
|
+
pass
|
|
275
|
+
except DeviceNotRegisteredError as e:
|
|
276
|
+
# No device registered - guide user to register device first
|
|
277
|
+
click.echo(f"\n{e}", err=True)
|
|
278
|
+
raise SystemExit(1)
|
|
279
|
+
except TwoFactorAuthRequiredError as e:
|
|
280
|
+
# 2FA required - guide user to run auth command first
|
|
281
|
+
click.echo(f"\n{e}", err=True)
|
|
282
|
+
raise SystemExit(1)
|
|
283
|
+
except Exception as e:
|
|
284
|
+
click.echo(f"Server error: {e}", err=True)
|
|
285
|
+
raise SystemExit(1)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
# auth command
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
@main.command()
|
|
293
|
+
@_env_file_option
|
|
294
|
+
def auth(env_file: str | None) -> None:
|
|
295
|
+
"""Authenticate with Webull API and obtain access token.
|
|
296
|
+
|
|
297
|
+
This command handles the token creation and 2FA flow:
|
|
298
|
+
1. Loads configuration from .env file
|
|
299
|
+
2. Initiates token creation with Webull API
|
|
300
|
+
3. If 2FA is required, SDK polls until user approves in Webull App
|
|
301
|
+
4. Saves token locally for future use
|
|
302
|
+
|
|
303
|
+
The token is valid for 15 days and auto-refreshes on use.
|
|
304
|
+
"""
|
|
305
|
+
from webull_openapi_mcp.sdk_client import DeviceNotRegisteredError, WebullSDKClient
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
config = load_config(env_file)
|
|
309
|
+
validate_config(config)
|
|
310
|
+
except ConfigError as e:
|
|
311
|
+
click.echo(f"Configuration error: {e}", err=True)
|
|
312
|
+
raise SystemExit(1)
|
|
313
|
+
|
|
314
|
+
click.echo("Webull OpenAPI Authentication")
|
|
315
|
+
click.echo("=" * 40)
|
|
316
|
+
click.echo(f"\nRegion: {config.region_id.upper()}")
|
|
317
|
+
click.echo(f"Environment: {config.environment.upper()}")
|
|
318
|
+
click.echo(f"Token Directory: {config.token_dir or './conf (default)'}")
|
|
319
|
+
|
|
320
|
+
click.echo("\nInitiating authentication...")
|
|
321
|
+
click.echo("If 2FA is required, please approve the request in your Webull app.")
|
|
322
|
+
click.echo("The SDK will wait for your approval (up to 5 minutes)...\n")
|
|
323
|
+
|
|
324
|
+
sdk = WebullSDKClient(config)
|
|
325
|
+
try:
|
|
326
|
+
# Use interactive mode for longer timeout (300s vs 10s)
|
|
327
|
+
sdk.initialize(interactive=True)
|
|
328
|
+
click.echo("✓ Authentication successful!")
|
|
329
|
+
click.echo(f"\nYou can now start the MCP server:")
|
|
330
|
+
click.echo(f" webull-openapi-mcp serve")
|
|
331
|
+
except DeviceNotRegisteredError as e:
|
|
332
|
+
# NO_AVAILABLE_DEVICE - device not registered, need to register first
|
|
333
|
+
click.echo(f"\n✗ {e}", err=True)
|
|
334
|
+
raise SystemExit(1)
|
|
335
|
+
except Exception as e:
|
|
336
|
+
click.echo(f"\n✗ Authentication failed: {e}", err=True)
|
|
337
|
+
raise SystemExit(1)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
# status command
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
@main.command()
|
|
345
|
+
@_env_file_option
|
|
346
|
+
def status(env_file: str | None) -> None:
|
|
347
|
+
"""Show configuration status and environment information.
|
|
348
|
+
|
|
349
|
+
Displays configuration validity, masked credentials summary,
|
|
350
|
+
and current environment settings.
|
|
351
|
+
"""
|
|
352
|
+
click.echo("Webull MCP Server Status")
|
|
353
|
+
click.echo("=" * 40)
|
|
354
|
+
|
|
355
|
+
# Try to load config
|
|
356
|
+
try:
|
|
357
|
+
config = load_config(env_file)
|
|
358
|
+
config_loaded = True
|
|
359
|
+
except Exception as e:
|
|
360
|
+
click.echo(f"\nConfiguration: FAILED to load")
|
|
361
|
+
click.echo(f" Error: {e}")
|
|
362
|
+
config_loaded = False
|
|
363
|
+
config = None
|
|
364
|
+
|
|
365
|
+
if config_loaded and config is not None:
|
|
366
|
+
# Validate config
|
|
367
|
+
try:
|
|
368
|
+
validate_config(config)
|
|
369
|
+
click.echo(f"\nConfiguration: VALID")
|
|
370
|
+
except ConfigError as e:
|
|
371
|
+
click.echo(f"\nConfiguration: INVALID")
|
|
372
|
+
click.echo(f" Error: {e}")
|
|
373
|
+
|
|
374
|
+
# Show masked credentials
|
|
375
|
+
click.echo(f"\nCredentials:")
|
|
376
|
+
click.echo(f" App Key: {_mask_secret(config.app_key)}")
|
|
377
|
+
click.echo(f" App Secret: {_mask_secret(config.app_secret)}")
|
|
378
|
+
|
|
379
|
+
# Show environment settings
|
|
380
|
+
click.echo(f"\nEnvironment:")
|
|
381
|
+
click.echo(f" Region: {config.region_id}")
|
|
382
|
+
click.echo(f" Environment: {config.environment}")
|
|
383
|
+
click.echo(f" Token Dir: {config.token_dir or '(SDK default)'}")
|
|
384
|
+
|
|
385
|
+
# Show risk control settings
|
|
386
|
+
click.echo(f"\nRisk Control:")
|
|
387
|
+
click.echo(f" Max Order Notional: ${config.max_order_notional:,.2f}")
|
|
388
|
+
click.echo(f" Max Order Quantity: {config.max_order_quantity:,.0f}")
|
|
389
|
+
if config.symbol_whitelist:
|
|
390
|
+
click.echo(f" Symbol Whitelist: {', '.join(config.symbol_whitelist)}")
|
|
391
|
+
else:
|
|
392
|
+
click.echo(f" Symbol Whitelist: (no restriction)")
|
|
393
|
+
|
|
394
|
+
# Show audit settings
|
|
395
|
+
click.echo(f"\nAudit:")
|
|
396
|
+
click.echo(f" Log File: {config.audit_log_file or '(stderr only)'}")
|
|
397
|
+
|
|
398
|
+
# Show system info
|
|
399
|
+
click.echo(f"\nSystem:")
|
|
400
|
+
click.echo(f" Python: {sys.version.split()[0]}")
|
|
401
|
+
click.echo(f" Platform: {sys.platform}")
|
|
402
|
+
click.echo(f" Version: {__version__}")
|
|
403
|
+
|
|
404
|
+
# Show env file path
|
|
405
|
+
env_path = env_file or ".env"
|
|
406
|
+
if Path(env_path).exists():
|
|
407
|
+
click.echo(f" Env File: {env_path} (exists)")
|
|
408
|
+
else:
|
|
409
|
+
click.echo(f" Env File: {env_path} (not found)")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ---------------------------------------------------------------------------
|
|
413
|
+
# tools command
|
|
414
|
+
# ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
@main.command()
|
|
417
|
+
@_env_file_option
|
|
418
|
+
def tools(env_file: str | None) -> None:
|
|
419
|
+
"""List all available MCP tools with descriptions.
|
|
420
|
+
|
|
421
|
+
Shows the tools provided by the Webull MCP Server,
|
|
422
|
+
organized by Webull OpenAPI structure.
|
|
423
|
+
"""
|
|
424
|
+
from webull_openapi_mcp.server import build_server
|
|
425
|
+
|
|
426
|
+
try:
|
|
427
|
+
config = load_config(env_file)
|
|
428
|
+
except Exception:
|
|
429
|
+
# Use minimal config for listing tools
|
|
430
|
+
config = load_config(None)
|
|
431
|
+
|
|
432
|
+
server = build_server(config)
|
|
433
|
+
|
|
434
|
+
click.echo("Webull OpenAPI MCP Server Tools")
|
|
435
|
+
click.echo("=" * 60)
|
|
436
|
+
|
|
437
|
+
# List registered tools from the FastMCP server
|
|
438
|
+
import asyncio
|
|
439
|
+
tool_list = asyncio.get_event_loop().run_until_complete(server.list_tools())
|
|
440
|
+
click.echo(f"\nTotal: {len(tool_list)} tools\n")
|
|
441
|
+
|
|
442
|
+
for tool in sorted(tool_list, key=lambda t: t.name):
|
|
443
|
+
description = tool.description or ""
|
|
444
|
+
summary = description.split("\n")[0].strip()
|
|
445
|
+
click.echo(f" {tool.name}")
|
|
446
|
+
click.echo(f" {summary}")
|
|
447
|
+
click.echo()
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
if __name__ == "__main__":
|
|
451
|
+
main()
|