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.
Files changed (33) hide show
  1. webull_openapi_mcp/__init__.py +3 -0
  2. webull_openapi_mcp/__main__.py +6 -0
  3. webull_openapi_mcp/audit.py +161 -0
  4. webull_openapi_mcp/cli.py +451 -0
  5. webull_openapi_mcp/config.py +164 -0
  6. webull_openapi_mcp/constants.py +94 -0
  7. webull_openapi_mcp/errors.py +160 -0
  8. webull_openapi_mcp/formatters.py +1059 -0
  9. webull_openapi_mcp/guards.py +539 -0
  10. webull_openapi_mcp/region_config.py +124 -0
  11. webull_openapi_mcp/sdk_client.py +251 -0
  12. webull_openapi_mcp/server.py +146 -0
  13. webull_openapi_mcp/tools/__init__.py +41 -0
  14. webull_openapi_mcp/tools/market_data/__init__.py +20 -0
  15. webull_openapi_mcp/tools/market_data/crypto.py +83 -0
  16. webull_openapi_mcp/tools/market_data/event.py +139 -0
  17. webull_openapi_mcp/tools/market_data/futures.py +172 -0
  18. webull_openapi_mcp/tools/market_data/stock.py +209 -0
  19. webull_openapi_mcp/tools/trading/__init__.py +56 -0
  20. webull_openapi_mcp/tools/trading/account.py +129 -0
  21. webull_openapi_mcp/tools/trading/assets.py +65 -0
  22. webull_openapi_mcp/tools/trading/crypto_order.py +171 -0
  23. webull_openapi_mcp/tools/trading/event_order.py +177 -0
  24. webull_openapi_mcp/tools/trading/futures_order.py +198 -0
  25. webull_openapi_mcp/tools/trading/instrument.py +282 -0
  26. webull_openapi_mcp/tools/trading/option_order.py +467 -0
  27. webull_openapi_mcp/tools/trading/order.py +158 -0
  28. webull_openapi_mcp/tools/trading/stock_order.py +591 -0
  29. webull_openapi_mcp-0.1.0.dist-info/METADATA +356 -0
  30. webull_openapi_mcp-0.1.0.dist-info/RECORD +33 -0
  31. webull_openapi_mcp-0.1.0.dist-info/WHEEL +4 -0
  32. webull_openapi_mcp-0.1.0.dist-info/entry_points.txt +2 -0
  33. webull_openapi_mcp-0.1.0.dist-info/licenses/LICENSE +191 -0
@@ -0,0 +1,3 @@
1
+ """Webull OpenAPI MCP Server - AI assistant integration for Webull OpenAPI."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m webull_openapi_mcp`."""
2
+
3
+ from webull_openapi_mcp.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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()