smartoption-mcp 0.1.1__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.
@@ -0,0 +1,192 @@
1
+ Metadata-Version: 2.4
2
+ Name: smartoption-mcp
3
+ Version: 0.1.1
4
+ Summary: MCP server exposing the smartoption-ai customer auto-trade API as tools for AI agents (Claude Desktop / Claude Code / any MCP client).
5
+ Author-email: SmartOption <service.smartoption@gmail.com>
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://github.com/SmartOption/smartoption-ai
8
+ Project-URL: Repository, https://github.com/SmartOption/smartoption-ai
9
+ Project-URL: Issues, https://github.com/SmartOption/smartoption-ai/issues
10
+ Keywords: mcp,smartoption,claude,auto-trade,copy-trading
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Financial and Insurance Industry
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Operating System :: OS Independent
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: mcp>=1.2.0
21
+ Requires-Dist: httpx>=0.27.0
22
+
23
+ # smartoption-mcp
24
+
25
+ MCP server that exposes the smartoption-ai **customer auto-trade API** as tools
26
+ for AI agents (Claude Desktop, Claude Code, anything that speaks MCP).
27
+
28
+ Phase 1 is **read-only**: 5 tools covering copy rules, virtual lots, agent
29
+ status, parsed signal history (大单/喊单历史), and copy-run logs. Write
30
+ operations (creating/modifying rules, placing orders) will be added in
31
+ Phase 2 once the read path is battle-tested.
32
+
33
+ ## Architecture
34
+
35
+ ```
36
+ Claude / Agent
37
+ │ MCP stdio
38
+
39
+ smartoption-mcp ──HTTP + Bearer JWT──▶ backend /api/customer/auto-trade/*
40
+ ```
41
+
42
+ The server is a thin wrapper: each tool maps 1:1 (or close) to a customer
43
+ API endpoint, authenticated with a user JWT loaded from env at startup.
44
+
45
+ ## Install
46
+
47
+ ### End-user (recommended)
48
+
49
+ ```bash
50
+ pip install smartoption-mcp
51
+ # or, in an isolated venv:
52
+ python3.11 -m venv ~/.smartoption-mcp && ~/.smartoption-mcp/bin/pip install smartoption-mcp
53
+ ```
54
+
55
+ `smartoption-mcp` is published on [PyPI](https://pypi.org/project/smartoption-mcp/).
56
+ This installs the `smartoption-mcp` CLI entry point. Upgrade with
57
+ `pip install -U smartoption-mcp`.
58
+
59
+ ### Local dev (from this repo)
60
+
61
+ ```bash
62
+ cd mcp-server
63
+ python3.11 -m venv .venv
64
+ source .venv/bin/activate
65
+ pip install -e .
66
+ ```
67
+
68
+ ## Configure auth
69
+
70
+ **Recommended: long-lived API token** (1-year expiry, revocable any time).
71
+ Log into [portal.smartoption.ai](https://portal.smartoption.ai) → 个人中心
72
+ → "API Token(用于 MCP / 脚本)" → 起个名字 → 复制生成的 token。
73
+ 管理页面随时可以吊销。
74
+
75
+ **Fallback: session JWT** — if the API tokens page isn't deployed yet,
76
+ open DevTools → Application → Local Storage → copy the `access_token`
77
+ field. Expires in ~24h so you'll re-paste daily.
78
+
79
+ Either way, the token goes into `SMARTOPTION_JWT`. Set
80
+ `SMARTOPTION_API_BASE=https://api.smartoption.ai`.
81
+
82
+ Smoke test from a shell:
83
+
84
+ ```bash
85
+ export SMARTOPTION_API_BASE="https://api.smartoption.ai"
86
+ export SMARTOPTION_JWT="eyJ..." # no "Bearer " prefix
87
+ .venv/bin/python -c "from smartoption_mcp.client import SmartoptionClient; \
88
+ print(SmartoptionClient().list_agents())"
89
+ ```
90
+
91
+ ## Register with Claude Code / Claude Desktop
92
+
93
+ **Claude Code** (this repo's primary host) — edit `~/.claude.json` and add
94
+ under the top-level `mcpServers` key:
95
+
96
+ ```json
97
+ {
98
+ "mcpServers": {
99
+ "smartoption": {
100
+ "command": "smartoption-mcp",
101
+ "env": {
102
+ "SMARTOPTION_API_BASE": "https://api.smartoption.ai",
103
+ "SMARTOPTION_JWT": "eyJ..."
104
+ }
105
+ }
106
+ }
107
+ }
108
+ ```
109
+
110
+ Restart Claude Code (or start a new session). The 5 `smartoption` tools
111
+ should appear in the tool list.
112
+
113
+ **Claude Desktop** — same JSON shape, but the file lives at
114
+ `~/Library/Application Support/Claude/claude_desktop_config.json`. Restart
115
+ the app after editing.
116
+
117
+ Once registered, you should be able to ask things like:
118
+
119
+ - "查一下最近一周苹果相关的喊单"
120
+ - "我现在的虚拟仓有哪些标的?"
121
+ - "我的跟单 agent 在线吗?最近一次心跳是什么时候?"
122
+ - "今天 agent 跑了几条信号,有没有被跳过的?"
123
+
124
+ ## Tools
125
+
126
+ ### Read (Phase 1)
127
+
128
+ | Tool | Endpoint | Purpose |
129
+ |---|---|---|
130
+ | `list_copy_rules` | `GET /copy-rules` | Current copy-trading rules |
131
+ | `list_virtual_lots` | `GET /virtual-ledger` | Open virtual lots + qty_by_key |
132
+ | `get_agent_status` | `GET /agents` | Per-user agent online status |
133
+ | `query_signal_history` | `GET /signal-history` | Search parsed alerts (大单) |
134
+ | `list_run_logs` | `GET /copy-run-logs` | Per-signal execution outcomes |
135
+
136
+ ### Write (Phase 2)
137
+
138
+ | Tool | Endpoint | Purpose |
139
+ |---|---|---|
140
+ | `update_copy_rules` | `PUT /copy-rules` | Replace full copy-trading settings (rules + toggles) |
141
+ | `ensure_agent` | `POST /agents/ensure` | Create / provision the user's agent k8s pod |
142
+ | `refresh_broker_snapshot` | `POST /broker-account-snapshot/refresh` | Ask agent to push fresh broker snapshot |
143
+
144
+ ### Phase 2 extension (broker config + agent ops + signal detail + quant)
145
+
146
+ | Tool | Endpoint | Purpose |
147
+ |---|---|---|
148
+ | `list_broker_configurations` | `GET /broker-configurations` | All saved brokers + active (no creds returned) |
149
+ | `get_active_broker` | `GET /broker-configurations` | Just the active broker name + display name |
150
+ | `set_active_broker` | `PUT /active-broker` | Switch active broker — dry-run gated |
151
+ | `test_broker_connection` | `POST /broker-configurations/test-{ib,tiger,futu,moomoo}` | Verify creds against broker; does NOT save |
152
+ | `restart_agent_client` | `POST /agents/<id>/restart-client` | Restart in-pod agent process — dry-run gated |
153
+ | `stop_agent` | `POST /agents/<id>/stop` | Scale agent deployment to 0 — dry-run gated |
154
+ | `get_agent_logs` | `GET /agents/<id>/logs` | Tail recent agent-pod stdout |
155
+ | `get_signal_detail` | `GET /signal-history/<id>` | Full SignalForwardRecord for one signal |
156
+ | `get_signal_chain` | `GET /signal-history/<id>/chain-slots` | Paired/chained signal context (ROLL_UP pairs etc.) |
157
+ | `list_virtual_followers` | `GET /api/customer/virtual-followers` | User's virtual-follower accounts + equity summary |
158
+ | `list_quant_strategies` | `GET /api/customer/quant-strategies/list` | Available / subscribed quant strategies |
159
+ | `get_quant_strategy_snapshot` | `GET /api/customer/quant-strategies/<id>/snapshot` | One strategy's positions + canonical valuation |
160
+
161
+ **Confirmation model.** Destructive writes use a `confirmed: bool` flag,
162
+ defaulting to `False` for **dry-run**:
163
+
164
+ - `update_copy_rules(new_settings, confirmed=False)` → server fetches
165
+ current settings, returns a structured diff (top-level toggle changes +
166
+ rules added / removed / modified, keyed by `rule_id`). Nothing is
167
+ written. The model is instructed (via docstring) to show the diff to
168
+ the user and only call again with `confirmed=True` after approval.
169
+ - `ensure_agent(force_redeploy=True, confirmed=False)` → returns a
170
+ description of the redeploy without doing it. Default `force_redeploy=False`
171
+ is idempotent and skips the dry-run gate.
172
+ - `refresh_broker_snapshot` → no gate (non-destructive async request).
173
+
174
+ The host (Claude Code / Desktop) also shows tool-call arguments to the
175
+ user before each call, so `confirmed=True` is always visible in the
176
+ approval UI — the in-tool `confirmed` flag is a second belt on top of
177
+ that suspenders.
178
+
179
+ **Validation.** `update_copy_rules` rejects partial settings: the proposed
180
+ doc must contain `auto_buy_enabled`, `auto_sell_enabled`,
181
+ `use_all_matched_rules`, and `rules`. The model is expected to start from
182
+ `list_copy_rules`, mutate locally, and pass the full doc back —
183
+ preserving every rule's `rule_id` so the diff stays stable.
184
+
185
+ ## Roadmap
186
+
187
+ - **Phase 3 (✅ Phase 3a done)**: long-lived API tokens managed from the
188
+ customer portal — eliminates daily JWT re-paste, no protocol changes.
189
+ Tokens are still pasted into env; Phase 3b would be full MCP OAuth 2.1
190
+ with a remote HTTP MCP server so other users can connect from their own
191
+ Claude install without copy-paste. Deferred until there's a real
192
+ multi-user use case.
@@ -0,0 +1,170 @@
1
+ # smartoption-mcp
2
+
3
+ MCP server that exposes the smartoption-ai **customer auto-trade API** as tools
4
+ for AI agents (Claude Desktop, Claude Code, anything that speaks MCP).
5
+
6
+ Phase 1 is **read-only**: 5 tools covering copy rules, virtual lots, agent
7
+ status, parsed signal history (大单/喊单历史), and copy-run logs. Write
8
+ operations (creating/modifying rules, placing orders) will be added in
9
+ Phase 2 once the read path is battle-tested.
10
+
11
+ ## Architecture
12
+
13
+ ```
14
+ Claude / Agent
15
+ │ MCP stdio
16
+
17
+ smartoption-mcp ──HTTP + Bearer JWT──▶ backend /api/customer/auto-trade/*
18
+ ```
19
+
20
+ The server is a thin wrapper: each tool maps 1:1 (or close) to a customer
21
+ API endpoint, authenticated with a user JWT loaded from env at startup.
22
+
23
+ ## Install
24
+
25
+ ### End-user (recommended)
26
+
27
+ ```bash
28
+ pip install smartoption-mcp
29
+ # or, in an isolated venv:
30
+ python3.11 -m venv ~/.smartoption-mcp && ~/.smartoption-mcp/bin/pip install smartoption-mcp
31
+ ```
32
+
33
+ `smartoption-mcp` is published on [PyPI](https://pypi.org/project/smartoption-mcp/).
34
+ This installs the `smartoption-mcp` CLI entry point. Upgrade with
35
+ `pip install -U smartoption-mcp`.
36
+
37
+ ### Local dev (from this repo)
38
+
39
+ ```bash
40
+ cd mcp-server
41
+ python3.11 -m venv .venv
42
+ source .venv/bin/activate
43
+ pip install -e .
44
+ ```
45
+
46
+ ## Configure auth
47
+
48
+ **Recommended: long-lived API token** (1-year expiry, revocable any time).
49
+ Log into [portal.smartoption.ai](https://portal.smartoption.ai) → 个人中心
50
+ → "API Token(用于 MCP / 脚本)" → 起个名字 → 复制生成的 token。
51
+ 管理页面随时可以吊销。
52
+
53
+ **Fallback: session JWT** — if the API tokens page isn't deployed yet,
54
+ open DevTools → Application → Local Storage → copy the `access_token`
55
+ field. Expires in ~24h so you'll re-paste daily.
56
+
57
+ Either way, the token goes into `SMARTOPTION_JWT`. Set
58
+ `SMARTOPTION_API_BASE=https://api.smartoption.ai`.
59
+
60
+ Smoke test from a shell:
61
+
62
+ ```bash
63
+ export SMARTOPTION_API_BASE="https://api.smartoption.ai"
64
+ export SMARTOPTION_JWT="eyJ..." # no "Bearer " prefix
65
+ .venv/bin/python -c "from smartoption_mcp.client import SmartoptionClient; \
66
+ print(SmartoptionClient().list_agents())"
67
+ ```
68
+
69
+ ## Register with Claude Code / Claude Desktop
70
+
71
+ **Claude Code** (this repo's primary host) — edit `~/.claude.json` and add
72
+ under the top-level `mcpServers` key:
73
+
74
+ ```json
75
+ {
76
+ "mcpServers": {
77
+ "smartoption": {
78
+ "command": "smartoption-mcp",
79
+ "env": {
80
+ "SMARTOPTION_API_BASE": "https://api.smartoption.ai",
81
+ "SMARTOPTION_JWT": "eyJ..."
82
+ }
83
+ }
84
+ }
85
+ }
86
+ ```
87
+
88
+ Restart Claude Code (or start a new session). The 5 `smartoption` tools
89
+ should appear in the tool list.
90
+
91
+ **Claude Desktop** — same JSON shape, but the file lives at
92
+ `~/Library/Application Support/Claude/claude_desktop_config.json`. Restart
93
+ the app after editing.
94
+
95
+ Once registered, you should be able to ask things like:
96
+
97
+ - "查一下最近一周苹果相关的喊单"
98
+ - "我现在的虚拟仓有哪些标的?"
99
+ - "我的跟单 agent 在线吗?最近一次心跳是什么时候?"
100
+ - "今天 agent 跑了几条信号,有没有被跳过的?"
101
+
102
+ ## Tools
103
+
104
+ ### Read (Phase 1)
105
+
106
+ | Tool | Endpoint | Purpose |
107
+ |---|---|---|
108
+ | `list_copy_rules` | `GET /copy-rules` | Current copy-trading rules |
109
+ | `list_virtual_lots` | `GET /virtual-ledger` | Open virtual lots + qty_by_key |
110
+ | `get_agent_status` | `GET /agents` | Per-user agent online status |
111
+ | `query_signal_history` | `GET /signal-history` | Search parsed alerts (大单) |
112
+ | `list_run_logs` | `GET /copy-run-logs` | Per-signal execution outcomes |
113
+
114
+ ### Write (Phase 2)
115
+
116
+ | Tool | Endpoint | Purpose |
117
+ |---|---|---|
118
+ | `update_copy_rules` | `PUT /copy-rules` | Replace full copy-trading settings (rules + toggles) |
119
+ | `ensure_agent` | `POST /agents/ensure` | Create / provision the user's agent k8s pod |
120
+ | `refresh_broker_snapshot` | `POST /broker-account-snapshot/refresh` | Ask agent to push fresh broker snapshot |
121
+
122
+ ### Phase 2 extension (broker config + agent ops + signal detail + quant)
123
+
124
+ | Tool | Endpoint | Purpose |
125
+ |---|---|---|
126
+ | `list_broker_configurations` | `GET /broker-configurations` | All saved brokers + active (no creds returned) |
127
+ | `get_active_broker` | `GET /broker-configurations` | Just the active broker name + display name |
128
+ | `set_active_broker` | `PUT /active-broker` | Switch active broker — dry-run gated |
129
+ | `test_broker_connection` | `POST /broker-configurations/test-{ib,tiger,futu,moomoo}` | Verify creds against broker; does NOT save |
130
+ | `restart_agent_client` | `POST /agents/<id>/restart-client` | Restart in-pod agent process — dry-run gated |
131
+ | `stop_agent` | `POST /agents/<id>/stop` | Scale agent deployment to 0 — dry-run gated |
132
+ | `get_agent_logs` | `GET /agents/<id>/logs` | Tail recent agent-pod stdout |
133
+ | `get_signal_detail` | `GET /signal-history/<id>` | Full SignalForwardRecord for one signal |
134
+ | `get_signal_chain` | `GET /signal-history/<id>/chain-slots` | Paired/chained signal context (ROLL_UP pairs etc.) |
135
+ | `list_virtual_followers` | `GET /api/customer/virtual-followers` | User's virtual-follower accounts + equity summary |
136
+ | `list_quant_strategies` | `GET /api/customer/quant-strategies/list` | Available / subscribed quant strategies |
137
+ | `get_quant_strategy_snapshot` | `GET /api/customer/quant-strategies/<id>/snapshot` | One strategy's positions + canonical valuation |
138
+
139
+ **Confirmation model.** Destructive writes use a `confirmed: bool` flag,
140
+ defaulting to `False` for **dry-run**:
141
+
142
+ - `update_copy_rules(new_settings, confirmed=False)` → server fetches
143
+ current settings, returns a structured diff (top-level toggle changes +
144
+ rules added / removed / modified, keyed by `rule_id`). Nothing is
145
+ written. The model is instructed (via docstring) to show the diff to
146
+ the user and only call again with `confirmed=True` after approval.
147
+ - `ensure_agent(force_redeploy=True, confirmed=False)` → returns a
148
+ description of the redeploy without doing it. Default `force_redeploy=False`
149
+ is idempotent and skips the dry-run gate.
150
+ - `refresh_broker_snapshot` → no gate (non-destructive async request).
151
+
152
+ The host (Claude Code / Desktop) also shows tool-call arguments to the
153
+ user before each call, so `confirmed=True` is always visible in the
154
+ approval UI — the in-tool `confirmed` flag is a second belt on top of
155
+ that suspenders.
156
+
157
+ **Validation.** `update_copy_rules` rejects partial settings: the proposed
158
+ doc must contain `auto_buy_enabled`, `auto_sell_enabled`,
159
+ `use_all_matched_rules`, and `rules`. The model is expected to start from
160
+ `list_copy_rules`, mutate locally, and pass the full doc back —
161
+ preserving every rule's `rule_id` so the diff stays stable.
162
+
163
+ ## Roadmap
164
+
165
+ - **Phase 3 (✅ Phase 3a done)**: long-lived API tokens managed from the
166
+ customer portal — eliminates daily JWT re-paste, no protocol changes.
167
+ Tokens are still pasted into env; Phase 3b would be full MCP OAuth 2.1
168
+ with a remote HTTP MCP server so other users can connect from their own
169
+ Claude install without copy-paste. Deferred until there's a real
170
+ multi-user use case.
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "smartoption-mcp"
3
+ version = "0.1.1"
4
+ description = "MCP server exposing the smartoption-ai customer auto-trade API as tools for AI agents (Claude Desktop / Claude Code / any MCP client)."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = { text = "Proprietary" }
8
+ authors = [{ name = "SmartOption", email = "service.smartoption@gmail.com" }]
9
+ keywords = ["mcp", "smartoption", "claude", "auto-trade", "copy-trading"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "Intended Audience :: Financial and Insurance Industry",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+ dependencies = [
20
+ "mcp>=1.2.0",
21
+ "httpx>=0.27.0",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/SmartOption/smartoption-ai"
26
+ Repository = "https://github.com/SmartOption/smartoption-ai"
27
+ Issues = "https://github.com/SmartOption/smartoption-ai/issues"
28
+
29
+ [project.scripts]
30
+ smartoption-mcp = "smartoption_mcp.server:main"
31
+
32
+ [build-system]
33
+ requires = ["setuptools>=68"]
34
+ build-backend = "setuptools.build_meta"
35
+
36
+ [tool.setuptools.packages.find]
37
+ include = ["smartoption_mcp*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,166 @@
1
+ """Thin HTTP client for the smartoption-ai customer auto-trade API.
2
+
3
+ All endpoints under /api/customer/auto-trade are user-JWT scoped: the
4
+ authenticated user's id is derived from the token, so we never pass user_id
5
+ explicitly. The JWT comes from env (SMARTOPTION_JWT) at server startup.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ DEFAULT_BASE = "https://api.smartoption.ai"
16
+
17
+
18
+ class SmartoptionClient:
19
+ def __init__(self, base_url: str | None = None, jwt: str | None = None, timeout: float = 20.0):
20
+ self.base_url = (base_url or os.environ.get("SMARTOPTION_API_BASE") or DEFAULT_BASE).rstrip("/")
21
+ self.jwt = jwt or os.environ.get("SMARTOPTION_JWT") or ""
22
+ if not self.jwt:
23
+ raise RuntimeError("SMARTOPTION_JWT env var is required")
24
+ self._client = httpx.Client(
25
+ base_url=self.base_url,
26
+ timeout=timeout,
27
+ headers={"Authorization": f"Bearer {self.jwt}"},
28
+ )
29
+
30
+ def close(self) -> None:
31
+ self._client.close()
32
+
33
+ def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
34
+ clean = {k: v for k, v in (params or {}).items() if v is not None and v != ""}
35
+ r = self._client.get(path, params=clean)
36
+ r.raise_for_status()
37
+ return self._unwrap(r.json())
38
+
39
+ def _post(self, path: str, json_body: dict[str, Any] | None = None) -> Any:
40
+ r = self._client.post(path, json=json_body or {})
41
+ r.raise_for_status()
42
+ return self._unwrap(r.json())
43
+
44
+ def _put(self, path: str, json_body: dict[str, Any] | None = None) -> Any:
45
+ r = self._client.put(path, json=json_body or {})
46
+ r.raise_for_status()
47
+ return self._unwrap(r.json())
48
+
49
+ @staticmethod
50
+ def _unwrap(body: Any) -> Any:
51
+ # ResponseFormatter wraps payloads as {success, data, message}
52
+ if isinstance(body, dict) and "data" in body:
53
+ return body.get("data")
54
+ return body
55
+
56
+ def list_copy_rules(self) -> Any:
57
+ return self._get("/api/customer/auto-trade/copy-rules")
58
+
59
+ def list_virtual_lots(self) -> Any:
60
+ return self._get("/api/customer/auto-trade/virtual-ledger")
61
+
62
+ def list_agents(self) -> Any:
63
+ return self._get("/api/customer/auto-trade/agents")
64
+
65
+ def query_signal_history(
66
+ self,
67
+ page: int = 1,
68
+ per_page: int = 20,
69
+ raw_content_search: str | None = None,
70
+ message_source_ids: list[str] | None = None,
71
+ created_at_from: str | None = None,
72
+ created_at_to: str | None = None,
73
+ ) -> Any:
74
+ return self._get(
75
+ "/api/customer/auto-trade/signal-history",
76
+ {
77
+ "page": page,
78
+ "per_page": per_page,
79
+ "raw_content_search": raw_content_search,
80
+ "message_source_ids": ",".join(message_source_ids) if message_source_ids else None,
81
+ "created_at_from": created_at_from,
82
+ "created_at_to": created_at_to,
83
+ },
84
+ )
85
+
86
+ def put_copy_rules(self, new_settings: dict[str, Any]) -> Any:
87
+ return self._put("/api/customer/auto-trade/copy-rules", new_settings)
88
+
89
+ def ensure_agent(
90
+ self,
91
+ label: str | None = None,
92
+ provision_k8s: bool = True,
93
+ force_redeploy: bool = False,
94
+ ) -> Any:
95
+ body: dict[str, Any] = {"provision_k8s": provision_k8s, "force_redeploy": force_redeploy}
96
+ if label:
97
+ body["label"] = label
98
+ return self._post("/api/customer/auto-trade/agents/ensure", body)
99
+
100
+ def refresh_broker_snapshot(self, agent_id: str | None = None) -> Any:
101
+ body = {"agent_id": agent_id} if agent_id else {}
102
+ return self._post("/api/customer/auto-trade/broker-account-snapshot/refresh", body)
103
+
104
+ # ---- Phase 2: broker configuration ----
105
+ def list_broker_configurations(self) -> Any:
106
+ return self._get("/api/customer/auto-trade/broker-configurations")
107
+
108
+ def set_active_broker(self, broker_name: str | None) -> Any:
109
+ return self._put("/api/customer/auto-trade/active-broker", {"active_broker": broker_name})
110
+
111
+ def test_broker_connection(self, broker_name: str, credentials: dict[str, Any]) -> Any:
112
+ slug = broker_name.lower().strip()
113
+ if slug not in {"ib", "tiger", "futu", "moomoo"}:
114
+ raise ValueError(f"unsupported broker for connection test: {broker_name}")
115
+ return self._post(f"/api/customer/auto-trade/broker-configurations/test-{slug}", credentials)
116
+
117
+ # ---- Phase 2: agent ops ----
118
+ def restart_agent_client(self, agent_id: str) -> Any:
119
+ return self._post(f"/api/customer/auto-trade/agents/{agent_id}/restart-client")
120
+
121
+ def stop_agent(self, agent_id: str) -> Any:
122
+ return self._post(f"/api/customer/auto-trade/agents/{agent_id}/stop")
123
+
124
+ def get_agent_logs(self, agent_id: str, lines: int = 200) -> Any:
125
+ return self._get(f"/api/customer/auto-trade/agents/{agent_id}/logs", {"lines": lines})
126
+
127
+ # ---- Phase 2: signal detail ----
128
+ def get_signal_detail(self, record_id: str) -> Any:
129
+ return self._get(f"/api/customer/auto-trade/signal-history/{record_id}")
130
+
131
+ def get_signal_chain_slots(self, record_id: str) -> Any:
132
+ return self._get(f"/api/customer/auto-trade/signal-history/{record_id}/chain-slots")
133
+
134
+ # ---- Phase 2: virtual followers ----
135
+ def list_virtual_followers(self) -> Any:
136
+ return self._get("/api/customer/virtual-followers")
137
+
138
+ # ---- Phase 2: quant strategies ----
139
+ def list_quant_strategies(self) -> Any:
140
+ return self._get("/api/customer/quant-strategies/list")
141
+
142
+ def get_quant_strategy_snapshot(self, strategy_id: str) -> Any:
143
+ return self._get(f"/api/customer/quant-strategies/{strategy_id}/snapshot")
144
+
145
+ def list_run_logs(
146
+ self,
147
+ page: int = 1,
148
+ per_page: int = 30,
149
+ signal_id: str | None = None,
150
+ call_content: str | None = None,
151
+ message_source_ids: list[str] | None = None,
152
+ created_after: str | None = None,
153
+ created_before: str | None = None,
154
+ ) -> Any:
155
+ return self._get(
156
+ "/api/customer/auto-trade/copy-run-logs",
157
+ {
158
+ "page": page,
159
+ "per_page": per_page,
160
+ "signal_id": signal_id,
161
+ "call_content": call_content,
162
+ "message_source_ids": ",".join(message_source_ids) if message_source_ids else None,
163
+ "created_after": created_after,
164
+ "created_before": created_before,
165
+ },
166
+ )
@@ -0,0 +1,105 @@
1
+ """Structured diff for copy-trading settings dicts.
2
+
3
+ Goal: give the model (and the user reading via the model) a compact,
4
+ unambiguous view of what would change between current and proposed
5
+ settings. Diffs by rule_id where possible so reordering rules doesn't
6
+ produce noisy output.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+
14
+ _SCALAR_KEYS = (
15
+ "auto_buy_enabled",
16
+ "auto_sell_enabled",
17
+ "use_all_matched_rules",
18
+ "active_time_start",
19
+ "active_time_end",
20
+ "active_weekdays",
21
+ )
22
+
23
+
24
+ def diff_copy_settings(current: dict[str, Any], proposed: dict[str, Any]) -> dict[str, Any]:
25
+ changes: list[dict[str, Any]] = []
26
+
27
+ for key in _SCALAR_KEYS:
28
+ cv = current.get(key)
29
+ pv = proposed.get(key)
30
+ if cv != pv:
31
+ changes.append({"path": key, "before": cv, "after": pv})
32
+
33
+ cur_rules = {r.get("rule_id"): r for r in (current.get("rules") or []) if isinstance(r, dict)}
34
+ new_rules = {r.get("rule_id"): r for r in (proposed.get("rules") or []) if isinstance(r, dict)}
35
+
36
+ added_rules = [r for rid, r in new_rules.items() if rid and rid not in cur_rules]
37
+ removed_rules = [r for rid, r in cur_rules.items() if rid and rid not in new_rules]
38
+
39
+ # Rules in proposed without rule_id are treated as new (frontend assigns
40
+ # uuid server-side, so this is the normal "add a new rule" path).
41
+ added_rules += [r for r in (proposed.get("rules") or []) if isinstance(r, dict) and not r.get("rule_id")]
42
+
43
+ modified_rules: list[dict[str, Any]] = []
44
+ for rid, new_rule in new_rules.items():
45
+ if not rid or rid not in cur_rules:
46
+ continue
47
+ cur_rule = cur_rules[rid]
48
+ rule_changes = _shallow_field_diff(cur_rule, new_rule)
49
+ if rule_changes:
50
+ modified_rules.append(
51
+ {
52
+ "rule_id": rid,
53
+ "name": new_rule.get("name") or cur_rule.get("name"),
54
+ "fields": rule_changes,
55
+ }
56
+ )
57
+
58
+ return {
59
+ "top_level_changes": changes,
60
+ "rules_added": [
61
+ {"rule_id": r.get("rule_id"), "name": r.get("name"), "enabled": r.get("enabled")}
62
+ for r in added_rules
63
+ ],
64
+ "rules_removed": [
65
+ {"rule_id": r.get("rule_id"), "name": r.get("name")} for r in removed_rules
66
+ ],
67
+ "rules_modified": modified_rules,
68
+ "summary": {
69
+ "top_level_changed": len(changes),
70
+ "rules_added": len(added_rules),
71
+ "rules_removed": len(removed_rules),
72
+ "rules_modified": len(modified_rules),
73
+ },
74
+ }
75
+
76
+
77
+ def _shallow_field_diff(a: dict[str, Any], b: dict[str, Any]) -> list[dict[str, Any]]:
78
+ out: list[dict[str, Any]] = []
79
+ keys = set(a.keys()) | set(b.keys())
80
+ # These are server-set echo fields; ignore.
81
+ keys.discard("updated_at")
82
+ for k in sorted(keys):
83
+ if a.get(k) != b.get(k):
84
+ out.append({"field": k, "before": a.get(k), "after": b.get(k)})
85
+ return out
86
+
87
+
88
+ REQUIRED_TOP_LEVEL_KEYS = ("auto_buy_enabled", "auto_sell_enabled", "use_all_matched_rules", "rules")
89
+
90
+
91
+ def validate_full_settings(proposed: dict[str, Any]) -> list[str]:
92
+ """Returns a list of validation errors. Empty list = OK."""
93
+ errors: list[str] = []
94
+ if not isinstance(proposed, dict):
95
+ return ["new_settings must be an object"]
96
+ for k in REQUIRED_TOP_LEVEL_KEYS:
97
+ if k not in proposed:
98
+ errors.append(
99
+ f"missing required top-level key '{k}'. You must pass the FULL settings doc "
100
+ f"(start from list_copy_rules and mutate locally), not a partial patch."
101
+ )
102
+ rules = proposed.get("rules")
103
+ if "rules" in proposed and not isinstance(rules, list):
104
+ errors.append("'rules' must be a list")
105
+ return errors
@@ -0,0 +1,436 @@
1
+ """MCP server exposing read-only smartoption-ai customer auto-trade tools.
2
+
3
+ Phase 1 (read-only): 5 tools — copy rules, virtual lots, agent status,
4
+ signal history (大单/喊单历史), and copy-run logs. Write operations
5
+ (create/modify copy rules, place orders) are intentionally not exposed yet.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Any
12
+
13
+ from mcp.server.fastmcp import FastMCP
14
+
15
+ from .client import SmartoptionClient
16
+ from .diff import diff_copy_settings, validate_full_settings
17
+
18
+ mcp = FastMCP("smartoption")
19
+ _client: SmartoptionClient | None = None
20
+
21
+
22
+ def _c() -> SmartoptionClient:
23
+ global _client
24
+ if _client is None:
25
+ _client = SmartoptionClient()
26
+ return _client
27
+
28
+
29
+ def _dump(obj: Any) -> str:
30
+ return json.dumps(obj, ensure_ascii=False, default=str)
31
+
32
+
33
+ @mcp.tool()
34
+ def list_copy_rules() -> str:
35
+ """List the current user's copy-trading rules and global settings.
36
+
37
+ Returns the same payload the customer frontend uses to render the
38
+ CopyRules panel: enabled rules, source channel filters, sizing,
39
+ premium controls, ROLL_UP options, etc.
40
+ """
41
+ return _dump(_c().list_copy_rules())
42
+
43
+
44
+ @mcp.tool()
45
+ def list_virtual_lots() -> str:
46
+ """List the user's virtual-ledger open lots (CopyTradingVirtualLot rows).
47
+
48
+ Useful to answer "what virtual positions am I currently holding?" and
49
+ "what's the unrealized pnl on my paper-traded copies?". Includes a
50
+ qty_by_key map for quick aggregation by position_key.
51
+ """
52
+ return _dump(_c().list_virtual_lots())
53
+
54
+
55
+ @mcp.tool()
56
+ def get_agent_status() -> str:
57
+ """Return the user's auto-trade agent(s): online/offline, last heartbeat,
58
+ active broker, k8s pod state. Use this to answer "is my copy-trade
59
+ bot running?" / "did it disconnect?".
60
+ """
61
+ return _dump(_c().list_agents())
62
+
63
+
64
+ @mcp.tool()
65
+ def query_signal_history(
66
+ raw_content_search: str | None = None,
67
+ message_source_ids: list[str] | None = None,
68
+ created_at_from: str | None = None,
69
+ created_at_to: str | None = None,
70
+ page: int = 1,
71
+ per_page: int = 20,
72
+ ) -> str:
73
+ """Search parsed trader-alert signals (a.k.a. 历史喊单 / 大单).
74
+
75
+ Each row is a SignalForwardRecord with the original raw alert text and
76
+ the LLM-parsed structured signal (action, instrument_kind, option_legs,
77
+ size_relative, etc.).
78
+
79
+ Args:
80
+ raw_content_search: free-text substring match on the raw alert (e.g.
81
+ "AAPL", "苹果", a strike, an expiry). Server-side ILIKE match.
82
+ message_source_ids: optional list of MessageSource ObjectIds to scope
83
+ to particular Discord channels / X-API accounts / etc.
84
+ created_at_from / created_at_to: ISO 8601 timestamps to bound the
85
+ search window. Prefer narrow windows (a few days) for relevance.
86
+ page / per_page: standard pagination; per_page capped server-side at 100.
87
+ """
88
+ return _dump(
89
+ _c().query_signal_history(
90
+ page=page,
91
+ per_page=per_page,
92
+ raw_content_search=raw_content_search,
93
+ message_source_ids=message_source_ids,
94
+ created_at_from=created_at_from,
95
+ created_at_to=created_at_to,
96
+ )
97
+ )
98
+
99
+
100
+ @mcp.tool()
101
+ def list_run_logs(
102
+ signal_id: str | None = None,
103
+ call_content: str | None = None,
104
+ message_source_ids: list[str] | None = None,
105
+ created_after: str | None = None,
106
+ created_before: str | None = None,
107
+ page: int = 1,
108
+ per_page: int = 30,
109
+ ) -> str:
110
+ """List CopyTradingRunLog rows — the agent's per-signal execution outcomes.
111
+
112
+ Use this to answer "did my agent act on signal X?", "why was it skipped?",
113
+ "show me today's executions". Each row carries the matched rule, the
114
+ submitted broker orders (with canonical view), and an outcome code such
115
+ as `buy_open_submitted`, `roll_up_close_skipped_no_rule`, `premium_blocked`.
116
+
117
+ Args:
118
+ signal_id: filter to a specific upstream signal id.
119
+ call_content: substring match against the original alert text.
120
+ message_source_ids: scope to specific source channels.
121
+ created_after / created_before: ISO 8601 timestamps.
122
+ """
123
+ return _dump(
124
+ _c().list_run_logs(
125
+ page=page,
126
+ per_page=per_page,
127
+ signal_id=signal_id,
128
+ call_content=call_content,
129
+ message_source_ids=message_source_ids,
130
+ created_after=created_after,
131
+ created_before=created_before,
132
+ )
133
+ )
134
+
135
+
136
+ @mcp.tool()
137
+ def update_copy_rules(new_settings: dict, confirmed: bool = False) -> str:
138
+ """Update the user's full copy-trading settings (rules + global toggles).
139
+
140
+ ⚠️ This is a **destructive write**. The customer backend PUT endpoint
141
+ replaces the entire settings document, so `new_settings` MUST be the
142
+ complete doc (start from `list_copy_rules`, mutate locally — never
143
+ construct from scratch or pass a partial patch).
144
+
145
+ Two-phase usage (required):
146
+ 1. Call with `confirmed=False` (the default). The server fetches the
147
+ current settings, computes a structured diff vs `new_settings`,
148
+ and returns it WITHOUT writing. You MUST present this diff to the
149
+ user in natural language and get explicit approval.
150
+ 2. Only after the user approves, call again with the same
151
+ `new_settings` and `confirmed=True` to actually persist.
152
+
153
+ Required top-level keys in new_settings: auto_buy_enabled,
154
+ auto_sell_enabled, use_all_matched_rules, rules. Optional:
155
+ active_time_start, active_time_end, active_weekdays.
156
+
157
+ Rule changes diff by `rule_id`, so preserve `rule_id` on every rule
158
+ you keep — otherwise the rule will look removed-and-re-added.
159
+ """
160
+ errors = validate_full_settings(new_settings)
161
+ if errors:
162
+ return _dump({"ok": False, "errors": errors})
163
+
164
+ if not confirmed:
165
+ current = _c().list_copy_rules() or {}
166
+ diff = diff_copy_settings(current, new_settings)
167
+ return _dump(
168
+ {
169
+ "ok": True,
170
+ "mode": "dry_run",
171
+ "diff": diff,
172
+ "next_step": (
173
+ "Show this diff to the user. If they approve, call "
174
+ "update_copy_rules again with the same new_settings and confirmed=True."
175
+ ),
176
+ }
177
+ )
178
+
179
+ result = _c().put_copy_rules(new_settings)
180
+ return _dump({"ok": True, "mode": "applied", "result": result})
181
+
182
+
183
+ @mcp.tool()
184
+ def ensure_agent(
185
+ label: str | None = None,
186
+ force_redeploy: bool = False,
187
+ confirmed: bool = False,
188
+ ) -> str:
189
+ """Ensure the user has an auto-trade agent pod provisioned in k8s.
190
+
191
+ Default (label=None, force_redeploy=False) is **idempotent**: if an
192
+ agent already exists it just returns its state; if not, it creates
193
+ one and provisions the k8s deployment. This default mode does NOT
194
+ require `confirmed=True`.
195
+
196
+ `force_redeploy=True` is **destructive** — it tears down and rebuilds
197
+ the user's agent pod (existing in-flight orders / heartbeats will be
198
+ interrupted). For this mode you MUST first call with
199
+ `confirmed=False` (returns a description of what will happen) and
200
+ then with `confirmed=True` after user approval.
201
+ """
202
+ if force_redeploy and not confirmed:
203
+ return _dump(
204
+ {
205
+ "ok": True,
206
+ "mode": "dry_run",
207
+ "action": "force_redeploy_agent",
208
+ "warning": (
209
+ "This will tear down and rebuild the user's auto-trade agent k8s pod. "
210
+ "In-flight broker connections and heartbeats will reset. "
211
+ "Confirm with the user before calling again with confirmed=True."
212
+ ),
213
+ }
214
+ )
215
+ result = _c().ensure_agent(label=label, force_redeploy=force_redeploy)
216
+ return _dump({"ok": True, "mode": "applied", "result": result})
217
+
218
+
219
+ @mcp.tool()
220
+ def refresh_broker_snapshot(agent_id: str | None = None) -> str:
221
+ """Ask the user's auto-trade agent to push a fresh broker account
222
+ snapshot (positions + balances) up to the backend.
223
+
224
+ Non-destructive: just an async signal to the agent. Safe to call
225
+ without confirmation. If `agent_id` is omitted, uses the user's first
226
+ enabled agent.
227
+ """
228
+ return _dump(_c().refresh_broker_snapshot(agent_id=agent_id))
229
+
230
+
231
+ # =========================================================================
232
+ # Phase 2 tools — broker config, agent ops, signal detail, quant strategies
233
+ # =========================================================================
234
+
235
+
236
+ @mcp.tool()
237
+ def list_broker_configurations() -> str:
238
+ """List the user's broker configurations + which broker is currently active.
239
+
240
+ Returns the same payload the customer "券商配置" page renders: which
241
+ brokers have credentials saved (configured), which one is active
242
+ (`active_broker`), and per-broker metadata. Credentials themselves
243
+ are NOT returned (server-side scrub).
244
+
245
+ Use this to answer "我配了哪些券商" / "现在在用哪个 broker".
246
+ """
247
+ return _dump(_c().list_broker_configurations())
248
+
249
+
250
+ @mcp.tool()
251
+ def get_active_broker() -> str:
252
+ """Return just the currently active broker name (and display name).
253
+
254
+ Thin wrapper over `list_broker_configurations` that narrows to the
255
+ `active_broker` / `active_broker_display_name` / `active_broker_configured`
256
+ fields. Use when the caller only needs "which broker is in use right now".
257
+ """
258
+ data = _c().list_broker_configurations() or {}
259
+ return _dump(
260
+ {
261
+ "active_broker": data.get("active_broker"),
262
+ "active_broker_display_name": data.get("active_broker_display_name"),
263
+ "active_broker_configured": data.get("active_broker_configured"),
264
+ }
265
+ )
266
+
267
+
268
+ @mcp.tool()
269
+ def set_active_broker(broker_name: str | None, confirmed: bool = False) -> str:
270
+ """Switch the user's active broker (or clear it with broker_name=None).
271
+
272
+ ⚠️ Destructive: changes which broker the auto-trade agent will route
273
+ orders through. In-flight orders on the previous broker are NOT
274
+ cancelled, but new copy-trade signals will go to the new broker.
275
+
276
+ Two-phase usage:
277
+ 1. Call with confirmed=False to get a dry-run summary (current vs
278
+ target broker).
279
+ 2. After user approval, call with confirmed=True.
280
+
281
+ `broker_name` must already have credentials configured (use
282
+ `list_broker_configurations` to check); pass None to clear active.
283
+ """
284
+ if not confirmed:
285
+ current = _c().list_broker_configurations() or {}
286
+ return _dump(
287
+ {
288
+ "ok": True,
289
+ "mode": "dry_run",
290
+ "current_active_broker": current.get("active_broker"),
291
+ "target_active_broker": broker_name,
292
+ "next_step": "Call again with confirmed=True after user approval.",
293
+ }
294
+ )
295
+ return _dump({"ok": True, "mode": "applied", "result": _c().set_active_broker(broker_name)})
296
+
297
+
298
+ @mcp.tool()
299
+ def test_broker_connection(broker_name: str, credentials: dict) -> str:
300
+ """Run the broker connection-test workflow for IB / Tiger / Futu / Moomoo.
301
+
302
+ Spawns a one-shot pod (or equivalent dry connection) against the
303
+ submitted `credentials` and returns the broker-side login + auth
304
+ result. Does NOT save the credentials — purely a verification call.
305
+
306
+ `credentials` shape is broker-specific; see the customer "券商配置"
307
+ form for the exact keys per broker (e.g. IB needs trading_mode +
308
+ login_id + login_password).
309
+ """
310
+ return _dump(_c().test_broker_connection(broker_name, credentials))
311
+
312
+
313
+ @mcp.tool()
314
+ def restart_agent_client(agent_id: str, confirmed: bool = False) -> str:
315
+ """Restart the agent-client process inside a user's auto-trade pod.
316
+
317
+ ⚠️ Destructive: in-flight socket connections to the gateway reset,
318
+ pending broker order-status polls are interrupted briefly (re-issued
319
+ on restart). Use when the agent looks stuck or after pushing a
320
+ config change that needs a process restart.
321
+
322
+ Two-phase: call with confirmed=False to get a dry-run, then with
323
+ confirmed=True to actually restart.
324
+ """
325
+ if not confirmed:
326
+ return _dump(
327
+ {
328
+ "ok": True,
329
+ "mode": "dry_run",
330
+ "action": "restart_agent_client",
331
+ "agent_id": agent_id,
332
+ "next_step": "Call again with confirmed=True after user approval.",
333
+ }
334
+ )
335
+ return _dump({"ok": True, "mode": "applied", "result": _c().restart_agent_client(agent_id)})
336
+
337
+
338
+ @mcp.tool()
339
+ def stop_agent(agent_id: str, confirmed: bool = False) -> str:
340
+ """Stop the user's auto-trade agent (scale deployment to 0 replicas).
341
+
342
+ ⚠️ Destructive: the agent will stop receiving trading_signal events
343
+ and stop polling broker order status until restarted (via
344
+ `ensure_agent` or in the UI). Use when the user wants to pause
345
+ copy-trading without deleting their config.
346
+
347
+ Two-phase: confirmed=False for dry-run, confirmed=True to apply.
348
+ """
349
+ if not confirmed:
350
+ return _dump(
351
+ {
352
+ "ok": True,
353
+ "mode": "dry_run",
354
+ "action": "stop_agent",
355
+ "agent_id": agent_id,
356
+ "next_step": "Call again with confirmed=True after user approval.",
357
+ }
358
+ )
359
+ return _dump({"ok": True, "mode": "applied", "result": _c().stop_agent(agent_id)})
360
+
361
+
362
+ @mcp.tool()
363
+ def get_agent_logs(agent_id: str, lines: int = 200) -> str:
364
+ """Fetch recent stdout logs from the user's auto-trade agent pod.
365
+
366
+ `lines` is the tail length (default 200, server may cap). Useful for
367
+ "why didn't my agent act on this signal" / "is the agent crashing".
368
+ """
369
+ return _dump(_c().get_agent_logs(agent_id, lines=lines))
370
+
371
+
372
+ @mcp.tool()
373
+ def get_signal_detail(record_id: str) -> str:
374
+ """Fetch the full SignalForwardRecord for one signal id.
375
+
376
+ Includes the raw alert text, the LLM-parsed structured signal, the
377
+ upstream message_source metadata, and any backfill/normalize traces.
378
+ Use this after `query_signal_history` returns a row id that the user
379
+ wants to drill into.
380
+ """
381
+ return _dump(_c().get_signal_detail(record_id))
382
+
383
+
384
+ @mcp.tool()
385
+ def get_signal_chain(record_id: str) -> str:
386
+ """Return the chain-slot context (the same signal across the trader's
387
+ recent signal chain) for one SignalForwardRecord id.
388
+
389
+ Used by the frontend to show "this signal is part of a ROLL_UP pair"
390
+ / "previous open from same trader" / paired-leg relationships. Pairs
391
+ nicely with ROLL_UP debugging.
392
+ """
393
+ return _dump(_c().get_signal_chain_slots(record_id))
394
+
395
+
396
+ @mcp.tool()
397
+ def list_virtual_followers() -> str:
398
+ """List the user's virtual-follower accounts.
399
+
400
+ Each VirtualFollower mirrors a trader's signals into a sandboxed
401
+ VirtualAccount (no real broker), tracked end-to-end through the
402
+ canonical valuation pipeline. Returns id, label, the followed
403
+ message-source, and current equity summary.
404
+ """
405
+ return _dump(_c().list_virtual_followers())
406
+
407
+
408
+ @mcp.tool()
409
+ def list_quant_strategies() -> str:
410
+ """List quant strategies available / subscribed by the user.
411
+
412
+ Returns id, name, description, visibility (public/private), and the
413
+ user's subscription state. Pairs with `get_quant_strategy_snapshot`
414
+ for per-strategy 持仓 + 业绩 detail.
415
+ """
416
+ return _dump(_c().list_quant_strategies())
417
+
418
+
419
+ @mcp.tool()
420
+ def get_quant_strategy_snapshot(strategy_id: str) -> str:
421
+ """Fetch one quant strategy's current positions + performance snapshot.
422
+
423
+ Backed by the canonical virtual-account valuation
424
+ (compute_virtual_account_totals), so the numbers match what the
425
+ `AIStrategyDetail.vue` page renders: total_value, cash_balance,
426
+ positions_detail, win-rate / realized PnL metrics.
427
+ """
428
+ return _dump(_c().get_quant_strategy_snapshot(strategy_id))
429
+
430
+
431
+ def main() -> None:
432
+ mcp.run()
433
+
434
+
435
+ if __name__ == "__main__":
436
+ main()
@@ -0,0 +1,192 @@
1
+ Metadata-Version: 2.4
2
+ Name: smartoption-mcp
3
+ Version: 0.1.1
4
+ Summary: MCP server exposing the smartoption-ai customer auto-trade API as tools for AI agents (Claude Desktop / Claude Code / any MCP client).
5
+ Author-email: SmartOption <service.smartoption@gmail.com>
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://github.com/SmartOption/smartoption-ai
8
+ Project-URL: Repository, https://github.com/SmartOption/smartoption-ai
9
+ Project-URL: Issues, https://github.com/SmartOption/smartoption-ai/issues
10
+ Keywords: mcp,smartoption,claude,auto-trade,copy-trading
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Financial and Insurance Industry
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Operating System :: OS Independent
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: mcp>=1.2.0
21
+ Requires-Dist: httpx>=0.27.0
22
+
23
+ # smartoption-mcp
24
+
25
+ MCP server that exposes the smartoption-ai **customer auto-trade API** as tools
26
+ for AI agents (Claude Desktop, Claude Code, anything that speaks MCP).
27
+
28
+ Phase 1 is **read-only**: 5 tools covering copy rules, virtual lots, agent
29
+ status, parsed signal history (大单/喊单历史), and copy-run logs. Write
30
+ operations (creating/modifying rules, placing orders) will be added in
31
+ Phase 2 once the read path is battle-tested.
32
+
33
+ ## Architecture
34
+
35
+ ```
36
+ Claude / Agent
37
+ │ MCP stdio
38
+
39
+ smartoption-mcp ──HTTP + Bearer JWT──▶ backend /api/customer/auto-trade/*
40
+ ```
41
+
42
+ The server is a thin wrapper: each tool maps 1:1 (or close) to a customer
43
+ API endpoint, authenticated with a user JWT loaded from env at startup.
44
+
45
+ ## Install
46
+
47
+ ### End-user (recommended)
48
+
49
+ ```bash
50
+ pip install smartoption-mcp
51
+ # or, in an isolated venv:
52
+ python3.11 -m venv ~/.smartoption-mcp && ~/.smartoption-mcp/bin/pip install smartoption-mcp
53
+ ```
54
+
55
+ `smartoption-mcp` is published on [PyPI](https://pypi.org/project/smartoption-mcp/).
56
+ This installs the `smartoption-mcp` CLI entry point. Upgrade with
57
+ `pip install -U smartoption-mcp`.
58
+
59
+ ### Local dev (from this repo)
60
+
61
+ ```bash
62
+ cd mcp-server
63
+ python3.11 -m venv .venv
64
+ source .venv/bin/activate
65
+ pip install -e .
66
+ ```
67
+
68
+ ## Configure auth
69
+
70
+ **Recommended: long-lived API token** (1-year expiry, revocable any time).
71
+ Log into [portal.smartoption.ai](https://portal.smartoption.ai) → 个人中心
72
+ → "API Token(用于 MCP / 脚本)" → 起个名字 → 复制生成的 token。
73
+ 管理页面随时可以吊销。
74
+
75
+ **Fallback: session JWT** — if the API tokens page isn't deployed yet,
76
+ open DevTools → Application → Local Storage → copy the `access_token`
77
+ field. Expires in ~24h so you'll re-paste daily.
78
+
79
+ Either way, the token goes into `SMARTOPTION_JWT`. Set
80
+ `SMARTOPTION_API_BASE=https://api.smartoption.ai`.
81
+
82
+ Smoke test from a shell:
83
+
84
+ ```bash
85
+ export SMARTOPTION_API_BASE="https://api.smartoption.ai"
86
+ export SMARTOPTION_JWT="eyJ..." # no "Bearer " prefix
87
+ .venv/bin/python -c "from smartoption_mcp.client import SmartoptionClient; \
88
+ print(SmartoptionClient().list_agents())"
89
+ ```
90
+
91
+ ## Register with Claude Code / Claude Desktop
92
+
93
+ **Claude Code** (this repo's primary host) — edit `~/.claude.json` and add
94
+ under the top-level `mcpServers` key:
95
+
96
+ ```json
97
+ {
98
+ "mcpServers": {
99
+ "smartoption": {
100
+ "command": "smartoption-mcp",
101
+ "env": {
102
+ "SMARTOPTION_API_BASE": "https://api.smartoption.ai",
103
+ "SMARTOPTION_JWT": "eyJ..."
104
+ }
105
+ }
106
+ }
107
+ }
108
+ ```
109
+
110
+ Restart Claude Code (or start a new session). The 5 `smartoption` tools
111
+ should appear in the tool list.
112
+
113
+ **Claude Desktop** — same JSON shape, but the file lives at
114
+ `~/Library/Application Support/Claude/claude_desktop_config.json`. Restart
115
+ the app after editing.
116
+
117
+ Once registered, you should be able to ask things like:
118
+
119
+ - "查一下最近一周苹果相关的喊单"
120
+ - "我现在的虚拟仓有哪些标的?"
121
+ - "我的跟单 agent 在线吗?最近一次心跳是什么时候?"
122
+ - "今天 agent 跑了几条信号,有没有被跳过的?"
123
+
124
+ ## Tools
125
+
126
+ ### Read (Phase 1)
127
+
128
+ | Tool | Endpoint | Purpose |
129
+ |---|---|---|
130
+ | `list_copy_rules` | `GET /copy-rules` | Current copy-trading rules |
131
+ | `list_virtual_lots` | `GET /virtual-ledger` | Open virtual lots + qty_by_key |
132
+ | `get_agent_status` | `GET /agents` | Per-user agent online status |
133
+ | `query_signal_history` | `GET /signal-history` | Search parsed alerts (大单) |
134
+ | `list_run_logs` | `GET /copy-run-logs` | Per-signal execution outcomes |
135
+
136
+ ### Write (Phase 2)
137
+
138
+ | Tool | Endpoint | Purpose |
139
+ |---|---|---|
140
+ | `update_copy_rules` | `PUT /copy-rules` | Replace full copy-trading settings (rules + toggles) |
141
+ | `ensure_agent` | `POST /agents/ensure` | Create / provision the user's agent k8s pod |
142
+ | `refresh_broker_snapshot` | `POST /broker-account-snapshot/refresh` | Ask agent to push fresh broker snapshot |
143
+
144
+ ### Phase 2 extension (broker config + agent ops + signal detail + quant)
145
+
146
+ | Tool | Endpoint | Purpose |
147
+ |---|---|---|
148
+ | `list_broker_configurations` | `GET /broker-configurations` | All saved brokers + active (no creds returned) |
149
+ | `get_active_broker` | `GET /broker-configurations` | Just the active broker name + display name |
150
+ | `set_active_broker` | `PUT /active-broker` | Switch active broker — dry-run gated |
151
+ | `test_broker_connection` | `POST /broker-configurations/test-{ib,tiger,futu,moomoo}` | Verify creds against broker; does NOT save |
152
+ | `restart_agent_client` | `POST /agents/<id>/restart-client` | Restart in-pod agent process — dry-run gated |
153
+ | `stop_agent` | `POST /agents/<id>/stop` | Scale agent deployment to 0 — dry-run gated |
154
+ | `get_agent_logs` | `GET /agents/<id>/logs` | Tail recent agent-pod stdout |
155
+ | `get_signal_detail` | `GET /signal-history/<id>` | Full SignalForwardRecord for one signal |
156
+ | `get_signal_chain` | `GET /signal-history/<id>/chain-slots` | Paired/chained signal context (ROLL_UP pairs etc.) |
157
+ | `list_virtual_followers` | `GET /api/customer/virtual-followers` | User's virtual-follower accounts + equity summary |
158
+ | `list_quant_strategies` | `GET /api/customer/quant-strategies/list` | Available / subscribed quant strategies |
159
+ | `get_quant_strategy_snapshot` | `GET /api/customer/quant-strategies/<id>/snapshot` | One strategy's positions + canonical valuation |
160
+
161
+ **Confirmation model.** Destructive writes use a `confirmed: bool` flag,
162
+ defaulting to `False` for **dry-run**:
163
+
164
+ - `update_copy_rules(new_settings, confirmed=False)` → server fetches
165
+ current settings, returns a structured diff (top-level toggle changes +
166
+ rules added / removed / modified, keyed by `rule_id`). Nothing is
167
+ written. The model is instructed (via docstring) to show the diff to
168
+ the user and only call again with `confirmed=True` after approval.
169
+ - `ensure_agent(force_redeploy=True, confirmed=False)` → returns a
170
+ description of the redeploy without doing it. Default `force_redeploy=False`
171
+ is idempotent and skips the dry-run gate.
172
+ - `refresh_broker_snapshot` → no gate (non-destructive async request).
173
+
174
+ The host (Claude Code / Desktop) also shows tool-call arguments to the
175
+ user before each call, so `confirmed=True` is always visible in the
176
+ approval UI — the in-tool `confirmed` flag is a second belt on top of
177
+ that suspenders.
178
+
179
+ **Validation.** `update_copy_rules` rejects partial settings: the proposed
180
+ doc must contain `auto_buy_enabled`, `auto_sell_enabled`,
181
+ `use_all_matched_rules`, and `rules`. The model is expected to start from
182
+ `list_copy_rules`, mutate locally, and pass the full doc back —
183
+ preserving every rule's `rule_id` so the diff stays stable.
184
+
185
+ ## Roadmap
186
+
187
+ - **Phase 3 (✅ Phase 3a done)**: long-lived API tokens managed from the
188
+ customer portal — eliminates daily JWT re-paste, no protocol changes.
189
+ Tokens are still pasted into env; Phase 3b would be full MCP OAuth 2.1
190
+ with a remote HTTP MCP server so other users can connect from their own
191
+ Claude install without copy-paste. Deferred until there's a real
192
+ multi-user use case.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ smartoption_mcp/__init__.py
4
+ smartoption_mcp/client.py
5
+ smartoption_mcp/diff.py
6
+ smartoption_mcp/server.py
7
+ smartoption_mcp.egg-info/PKG-INFO
8
+ smartoption_mcp.egg-info/SOURCES.txt
9
+ smartoption_mcp.egg-info/dependency_links.txt
10
+ smartoption_mcp.egg-info/entry_points.txt
11
+ smartoption_mcp.egg-info/requires.txt
12
+ smartoption_mcp.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ smartoption-mcp = smartoption_mcp.server:main
@@ -0,0 +1,2 @@
1
+ mcp>=1.2.0
2
+ httpx>=0.27.0
@@ -0,0 +1 @@
1
+ smartoption_mcp