aria-code 4.1.3__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 (284) hide show
  1. agents/__init__.py +32 -0
  2. agents/base.py +190 -0
  3. agents/deep/__init__.py +37 -0
  4. agents/deep/calibration_loop.py +144 -0
  5. agents/deep/critic.py +125 -0
  6. agents/deep/deepen.py +193 -0
  7. agents/deep/models.py +149 -0
  8. agents/deep/pipeline.py +164 -0
  9. agents/deep/quant_fusion.py +192 -0
  10. agents/deep/themes.py +95 -0
  11. agents/deep/tiers.py +106 -0
  12. agents/financial/__init__.py +10 -0
  13. agents/financial/catalyst.py +279 -0
  14. agents/financial/debate.py +145 -0
  15. agents/financial/earnings.py +303 -0
  16. agents/financial/fundamental.py +159 -0
  17. agents/financial/macro.py +99 -0
  18. agents/financial/news.py +207 -0
  19. agents/financial/risk.py +132 -0
  20. agents/financial/sector.py +279 -0
  21. agents/financial/synthesis.py +274 -0
  22. agents/financial/technical.py +258 -0
  23. agents/portfolio_agent.py +333 -0
  24. agents/realty/__init__.py +62 -0
  25. agents/realty/asset_diagnosis.py +150 -0
  26. agents/realty/business_match.py +165 -0
  27. agents/realty/cashflow_verify.py +208 -0
  28. agents/realty/contract_rules.py +209 -0
  29. agents/realty/energy_anomaly.py +188 -0
  30. agents/realty/exit_settlement.py +207 -0
  31. agents/realty/fulfillment_risk.py +205 -0
  32. agents/realty/ops_optimize.py +159 -0
  33. agents/realty/revenue_share.py +214 -0
  34. agents/registry.py +144 -0
  35. agents/sports/__init__.py +0 -0
  36. agents/sports/football_agent.py +169 -0
  37. agents/team.py +289 -0
  38. aliyun_data_client.py +660 -0
  39. apps/README.md +12 -0
  40. apps/__init__.py +2 -0
  41. apps/channels/README.md +15 -0
  42. apps/cli/README.md +13 -0
  43. apps/cli/__init__.py +2 -0
  44. apps/cli/bootstrap.py +99 -0
  45. apps/cli/codegen_paths.py +29 -0
  46. apps/cli/commands/__init__.py +16 -0
  47. apps/cli/commands/analysis_cmds.py +288 -0
  48. apps/cli/commands/backtest_cmds.py +1887 -0
  49. apps/cli/commands/broker_cmds.py +1154 -0
  50. apps/cli/commands/business_workflow_cmds.py +289 -0
  51. apps/cli/commands/catalog.py +84 -0
  52. apps/cli/commands/data_cmds.py +405 -0
  53. apps/cli/commands/diagnostic_cmds.py +179 -0
  54. apps/cli/commands/diagnostic_ops_cmds.py +696 -0
  55. apps/cli/commands/finance_render.py +12 -0
  56. apps/cli/commands/market.py +399 -0
  57. apps/cli/commands/market_cmds.py +1276 -0
  58. apps/cli/commands/market_context.py +425 -0
  59. apps/cli/commands/market_render.py +7 -0
  60. apps/cli/commands/model_cmds.py +1579 -0
  61. apps/cli/commands/ops_cmds.py +668 -0
  62. apps/cli/commands/portfolio_cmds.py +962 -0
  63. apps/cli/commands/report.py +377 -0
  64. apps/cli/commands/scaffold_templates.py +617 -0
  65. apps/cli/commands/session_cmds.py +179 -0
  66. apps/cli/commands/session_ux_cmds.py +280 -0
  67. apps/cli/commands/team.py +588 -0
  68. apps/cli/commands/team_render.py +8 -0
  69. apps/cli/commands/ui_cmds.py +358 -0
  70. apps/cli/commands/workflow_cmds.py +279 -0
  71. apps/cli/commands/workspace_cmds.py +1414 -0
  72. apps/cli/config_paths.py +70 -0
  73. apps/cli/config_store.py +61 -0
  74. apps/cli/deterministic.py +122 -0
  75. apps/cli/direct.py +48 -0
  76. apps/cli/github_app_auth.py +135 -0
  77. apps/cli/handlers/__init__.py +11 -0
  78. apps/cli/handlers/broker_handlers.py +122 -0
  79. apps/cli/handlers/chart_handlers.py +1309 -0
  80. apps/cli/handlers/market_handlers.py +2509 -0
  81. apps/cli/handlers/realty_handlers.py +114 -0
  82. apps/cli/handlers/strategy_advice.py +82 -0
  83. apps/cli/hooks.py +180 -0
  84. apps/cli/i18n.py +284 -0
  85. apps/cli/intent.py +136 -0
  86. apps/cli/intent_router.py +217 -0
  87. apps/cli/lifecycle_hooks.py +48 -0
  88. apps/cli/main.py +29 -0
  89. apps/cli/market_metadata.py +135 -0
  90. apps/cli/market_universe.py +265 -0
  91. apps/cli/message_processing.py +257 -0
  92. apps/cli/plan_mode.py +139 -0
  93. apps/cli/plotly_html.py +15 -0
  94. apps/cli/prediction_feedback.py +202 -0
  95. apps/cli/preflight.py +497 -0
  96. apps/cli/project_aria.py +60 -0
  97. apps/cli/prompts/__init__.py +0 -0
  98. apps/cli/prompts/coding.py +658 -0
  99. apps/cli/prompts/system_prompts.py +531 -0
  100. apps/cli/prompts/ui.py +434 -0
  101. apps/cli/providers/__init__.py +1 -0
  102. apps/cli/providers/base.py +271 -0
  103. apps/cli/providers/chat_routing.py +80 -0
  104. apps/cli/providers/llm/__init__.py +1 -0
  105. apps/cli/providers/llm/ollama_stream.py +1170 -0
  106. apps/cli/providers/llm/sse_stream.py +216 -0
  107. apps/cli/providers/runtime_bridge.py +185 -0
  108. apps/cli/runtime_consumer.py +489 -0
  109. apps/cli/session_export.py +87 -0
  110. apps/cli/session_jsonl.py +207 -0
  111. apps/cli/session_store.py +112 -0
  112. apps/cli/todo_tracker.py +190 -0
  113. apps/cli/tools/__init__.py +40 -0
  114. apps/cli/tools/context.py +46 -0
  115. apps/cli/tools/file_tools.py +112 -0
  116. apps/cli/tools/market_tools.py +549 -0
  117. apps/cli/tools/notebook_tools.py +111 -0
  118. apps/cli/tools/system_tools.py +669 -0
  119. apps/cli/tools/write_tools.py +715 -0
  120. apps/cli/tradingview_bridge.py +434 -0
  121. apps/cli/update_check.py +152 -0
  122. apps/cli/utils/__init__.py +0 -0
  123. apps/cli/utils/market_detect.py +1578 -0
  124. apps/daemon/README.md +14 -0
  125. apps/vscode/README.md +115 -0
  126. apps/vscode/package.json +70 -0
  127. aria_cli.py +11636 -0
  128. aria_code-4.1.3.dist-info/METADATA +952 -0
  129. aria_code-4.1.3.dist-info/RECORD +284 -0
  130. aria_code-4.1.3.dist-info/WHEEL +5 -0
  131. aria_code-4.1.3.dist-info/entry_points.txt +2 -0
  132. aria_code-4.1.3.dist-info/licenses/LICENSE +121 -0
  133. aria_code-4.1.3.dist-info/top_level.txt +50 -0
  134. aria_daemon.py +1295 -0
  135. aria_feishu_bot.py +1359 -0
  136. aria_relay_client.py +182 -0
  137. aria_relay_server.py +405 -0
  138. aria_telegram_bot.py +202 -0
  139. ariarc.py +328 -0
  140. artifacts.py +491 -0
  141. backtest_report.py +472 -0
  142. brokers/__init__.py +72 -0
  143. brokers/base.py +207 -0
  144. brokers/capabilities.py +264 -0
  145. brokers/cn/__init__.py +10 -0
  146. brokers/cn/easytrader_broker.py +193 -0
  147. brokers/cn/futu_broker.py +194 -0
  148. brokers/cn/longbridge_broker.py +190 -0
  149. brokers/cn/tiger_broker.py +196 -0
  150. brokers/cn/xtquant_broker.py +175 -0
  151. brokers/config.py +364 -0
  152. brokers/intl/__init__.py +5 -0
  153. brokers/intl/alpaca_broker.py +183 -0
  154. brokers/intl/ibkr_broker.py +215 -0
  155. brokers/intl/webull_broker.py +156 -0
  156. brokers/paper_broker.py +259 -0
  157. brokers/planning.py +296 -0
  158. brokers/registry.py +181 -0
  159. brokers/trading.py +237 -0
  160. change_store.py +127 -0
  161. command_safety.py +19 -0
  162. computer_use_tools.py +504 -0
  163. dashboard_generator.py +578 -0
  164. data_analysis_tools.py +808 -0
  165. data_cleaner.py +483 -0
  166. data_service.py +481 -0
  167. datasources/__init__.py +23 -0
  168. datasources/base.py +166 -0
  169. datasources/router.py +221 -0
  170. datasources/sources/__init__.py +15 -0
  171. datasources/sources/akshare_source.py +269 -0
  172. datasources/sources/alpha_vantage_source.py +202 -0
  173. datasources/sources/edgar_source.py +218 -0
  174. datasources/sources/finnhub_source.py +197 -0
  175. datasources/sources/fred_source.py +219 -0
  176. datasources/sources/tushare_source.py +141 -0
  177. datasources/sources/web_scraper_source.py +278 -0
  178. datasources/sources/world_bank_source.py +205 -0
  179. datasources/sources/yfinance_source.py +152 -0
  180. demo_player.py +204 -0
  181. doctor.py +508 -0
  182. file_analysis_tools.py +734 -0
  183. finance_formulas.py +389 -0
  184. football_data_client.py +1670 -0
  185. intent_classifier.py +358 -0
  186. local_finance_tools.py +3221 -0
  187. local_llm_provider.py +552 -0
  188. macro_tools.py +368 -0
  189. market_data_client.py +1899 -0
  190. mcp_client.py +506 -0
  191. memory_manager.py +245 -0
  192. model_capability.py +416 -0
  193. notification_tools.py +248 -0
  194. packages/__init__.py +23 -0
  195. packages/aria_agents/__init__.py +5 -0
  196. packages/aria_agents/manifest.py +69 -0
  197. packages/aria_core/__init__.py +34 -0
  198. packages/aria_core/architecture.py +192 -0
  199. packages/aria_core/export.py +124 -0
  200. packages/aria_core/manifest.py +65 -0
  201. packages/aria_infra/__init__.py +15 -0
  202. packages/aria_infra/arthera.py +52 -0
  203. packages/aria_infra/doctor.py +246 -0
  204. packages/aria_infra/product.py +37 -0
  205. packages/aria_mcp/__init__.py +25 -0
  206. packages/aria_mcp/bridge.py +38 -0
  207. packages/aria_mcp/config.py +97 -0
  208. packages/aria_mcp/tools.py +61 -0
  209. packages/aria_sdk/__init__.py +19 -0
  210. packages/aria_sdk/client.py +396 -0
  211. packages/aria_sdk/providers.py +70 -0
  212. packages/aria_sdk/streaming.py +73 -0
  213. packages/aria_sdk/types.py +86 -0
  214. packages/aria_services/__init__.py +55 -0
  215. packages/aria_services/context.py +258 -0
  216. packages/aria_services/data.py +11 -0
  217. packages/aria_services/provider_health.py +189 -0
  218. packages/aria_services/registry.py +213 -0
  219. packages/aria_services/usage.py +138 -0
  220. packages/aria_skills/__init__.py +5 -0
  221. packages/aria_skills/registry.py +59 -0
  222. packages/aria_tools/__init__.py +5 -0
  223. packages/aria_tools/registry.py +128 -0
  224. packages/quant_engine/__init__.py +6 -0
  225. packages/quant_engine/sports/__init__.py +72 -0
  226. packages/quant_engine/sports/calibrator.py +353 -0
  227. packages/quant_engine/sports/dixon_coles.py +234 -0
  228. packages/quant_engine/sports/elo.py +299 -0
  229. packages/quant_engine/sports/form.py +188 -0
  230. packages/quant_engine/sports/h2h.py +195 -0
  231. packages/quant_engine/sports/ml_model.py +354 -0
  232. packages/quant_engine/sports/predictor.py +311 -0
  233. packages/quant_engine/sports/tracker.py +664 -0
  234. packages/quant_engine/stochastic/__init__.py +27 -0
  235. packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
  236. packages/quant_engine/stochastic/ito_calculus.py +477 -0
  237. packages/quant_engine/stochastic/kelly_criterion.py +181 -0
  238. packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
  239. packages/quant_engine/stochastic/options_pricing.py +573 -0
  240. packages/quant_engine/stochastic/stochastic_processes.py +90 -0
  241. plan_utils.py +194 -0
  242. plugin_loader.py +328 -0
  243. portfolio_ledger.py +262 -0
  244. privacy/__init__.py +5 -0
  245. privacy/feedback.py +123 -0
  246. project_tools.py +525 -0
  247. providers/__init__.py +30 -0
  248. providers/llm/__init__.py +19 -0
  249. providers/llm/anthropic.py +184 -0
  250. providers/llm/base.py +139 -0
  251. providers/llm/ollama.py +128 -0
  252. providers/llm/openai_compat.py +282 -0
  253. providers/llm/registry.py +358 -0
  254. realty_data_tools.py +659 -0
  255. report_generator.py +1314 -0
  256. runtime/__init__.py +103 -0
  257. runtime/agent_loop.py +1183 -0
  258. runtime/approval.py +51 -0
  259. runtime/events.py +102 -0
  260. runtime/gateway.py +128 -0
  261. runtime/lsp.py +346 -0
  262. runtime/subagent.py +258 -0
  263. runtime/tool_executor.py +104 -0
  264. runtime/tool_policy.py +106 -0
  265. safety/__init__.py +21 -0
  266. safety/permissions.py +275 -0
  267. setup_wizard.py +653 -0
  268. strategy_vault.py +420 -0
  269. ui/__init__.py +100 -0
  270. ui/banner.py +310 -0
  271. ui/completer.py +391 -0
  272. ui/console.py +271 -0
  273. ui/image_render.py +243 -0
  274. ui/input_box.py +376 -0
  275. ui/picker.py +195 -0
  276. ui/render/__init__.py +11 -0
  277. ui/render/finance.py +1480 -0
  278. ui/render/market.py +225 -0
  279. ui/render/output.py +681 -0
  280. ui/render/team.py +346 -0
  281. ui/robot.py +235 -0
  282. workspace/__init__.py +6 -0
  283. workspace/files.py +170 -0
  284. workspace/verify.py +113 -0
mcp_client.py ADDED
@@ -0,0 +1,506 @@
1
+ """
2
+ mcp_client.py — MCP (Model Context Protocol) client for Aria Code.
3
+
4
+ Connects to any MCP server via stdio transport and exposes its tools as
5
+ first-class LOCAL_TOOLS in the Aria Code tool loop.
6
+
7
+ Config file: ~/.arthera/mcp_servers.json
8
+ Example::
9
+
10
+ {
11
+ "servers": [
12
+ {
13
+ "name": "quant_engine",
14
+ "command": "python3",
15
+ "args": ["/path/to/mcp_server.py"],
16
+ "env": {"PYTHONPATH": "/path/to/project"},
17
+ "description": "Arthera quant engine MCP tools"
18
+ },
19
+ {
20
+ "name": "filesystem",
21
+ "command": "npx",
22
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"],
23
+ "description": "Filesystem access"
24
+ }
25
+ ]
26
+ }
27
+
28
+ Usage::
29
+
30
+ registry = MCPToolRegistry()
31
+ await registry.start_all()
32
+ registry.register_into(LOCAL_TOOLS, LOCAL_TOOL_SCHEMAS)
33
+
34
+ # Tools are now callable as regular LOCAL_TOOLS entries
35
+ result = await registry.call_tool("quant_engine/backtest_strategy", {"symbol": "AAPL"})
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import asyncio
41
+ import json
42
+ import logging
43
+ import os
44
+ import pathlib
45
+ import traceback
46
+ from typing import Any, Callable, Dict, List, Optional, Tuple
47
+
48
+ logger = logging.getLogger(__name__)
49
+
50
+ MCP_CONFIG_PATH = pathlib.Path.home() / ".arthera" / "mcp_servers.json"
51
+
52
+ # MCP JSON-RPC protocol version
53
+ MCP_PROTOCOL_VERSION = "2024-11-05"
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # JSON-RPC helpers
58
+ # ---------------------------------------------------------------------------
59
+
60
+ def _make_request(method: str, params: Any = None, req_id: int = 1) -> bytes:
61
+ msg = {"jsonrpc": "2.0", "id": req_id, "method": method}
62
+ if params is not None:
63
+ msg["params"] = params
64
+ return (json.dumps(msg) + "\n").encode()
65
+
66
+
67
+ def _make_notification(method: str, params: Any = None) -> bytes:
68
+ msg = {"jsonrpc": "2.0", "method": method}
69
+ if params is not None:
70
+ msg["params"] = params
71
+ return (json.dumps(msg) + "\n").encode()
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # MCPServer — one subprocess connection
76
+ # ---------------------------------------------------------------------------
77
+
78
+ class MCPServer:
79
+ """
80
+ Manages a single MCP server subprocess (stdio transport).
81
+
82
+ Lifecycle: start() → list_tools() → call_tool(…) → stop()
83
+ """
84
+
85
+ def __init__(
86
+ self,
87
+ name: str,
88
+ command: str,
89
+ args: List[str],
90
+ env: Optional[Dict[str, str]] = None,
91
+ description: str = "",
92
+ timeout: float = 30.0,
93
+ ):
94
+ self.name = name
95
+ self.command = command
96
+ self.args = args
97
+ self.env = env or {}
98
+ self.description = description
99
+ self.timeout = timeout
100
+
101
+ self._proc: Optional[asyncio.subprocess.Process] = None
102
+ self._req_id: int = 0
103
+ self._pending: Dict[int, asyncio.Future] = {}
104
+ self._reader_task: Optional[asyncio.Task] = None
105
+ self._tools: List[Dict[str, Any]] = []
106
+ self._running = False
107
+
108
+ # ── internal ───────────────────────────────────────────────────────────
109
+
110
+ def _next_id(self) -> int:
111
+ self._req_id += 1
112
+ return self._req_id
113
+
114
+ async def _send(self, data: bytes):
115
+ if self._proc and self._proc.stdin:
116
+ self._proc.stdin.write(data)
117
+ await self._proc.stdin.drain()
118
+
119
+ async def _reader_loop(self):
120
+ """Background task: read lines from server stdout and dispatch to futures."""
121
+ if not self._proc or not self._proc.stdout:
122
+ return
123
+ try:
124
+ async for raw in self._proc.stdout:
125
+ line = raw.decode("utf-8", errors="ignore").strip()
126
+ if not line:
127
+ continue
128
+ try:
129
+ msg = json.loads(line)
130
+ except json.JSONDecodeError:
131
+ logger.debug("[MCP:%s] Non-JSON: %s", self.name, line[:100])
132
+ continue
133
+
134
+ req_id = msg.get("id")
135
+ if req_id is not None and req_id in self._pending:
136
+ fut = self._pending.pop(req_id)
137
+ if not fut.done():
138
+ if "error" in msg:
139
+ fut.set_exception(RuntimeError(str(msg["error"])))
140
+ else:
141
+ fut.set_result(msg.get("result"))
142
+ except asyncio.CancelledError:
143
+ pass
144
+ except Exception as exc:
145
+ logger.debug("[MCP:%s] Reader loop error: %s", self.name, exc)
146
+
147
+ async def _request(self, method: str, params: Any = None) -> Any:
148
+ rid = self._next_id()
149
+ fut = asyncio.get_event_loop().create_future()
150
+ self._pending[rid] = fut
151
+ await self._send(_make_request(method, params, rid))
152
+ try:
153
+ return await asyncio.wait_for(fut, timeout=self.timeout)
154
+ except asyncio.TimeoutError:
155
+ self._pending.pop(rid, None)
156
+ raise TimeoutError(f"MCP {self.name}/{method} timed out after {self.timeout}s")
157
+
158
+ # ── public API ─────────────────────────────────────────────────────────
159
+
160
+ async def start(self) -> bool:
161
+ """Start the server subprocess and perform MCP handshake."""
162
+ if self._running:
163
+ return True
164
+ try:
165
+ env = {**os.environ, **self.env}
166
+ self._proc = await asyncio.create_subprocess_exec(
167
+ self.command, *self.args,
168
+ stdin=asyncio.subprocess.PIPE,
169
+ stdout=asyncio.subprocess.PIPE,
170
+ stderr=asyncio.subprocess.PIPE,
171
+ env=env,
172
+ )
173
+ except FileNotFoundError as exc:
174
+ logger.warning("[MCP:%s] Command not found: %s", self.name, exc)
175
+ return False
176
+ except Exception as exc:
177
+ logger.warning("[MCP:%s] Failed to start: %s", self.name, exc)
178
+ return False
179
+
180
+ self._reader_task = asyncio.create_task(self._reader_loop())
181
+
182
+ # MCP initialize handshake
183
+ try:
184
+ await self._request("initialize", {
185
+ "protocolVersion": MCP_PROTOCOL_VERSION,
186
+ "capabilities": {"tools": {}},
187
+ "clientInfo": {"name": "aria-code", "version": "3.0"},
188
+ })
189
+ await self._send(_make_notification("notifications/initialized"))
190
+ except Exception as exc:
191
+ logger.warning("[MCP:%s] Handshake failed: %s", self.name, exc)
192
+ await self.stop()
193
+ return False
194
+
195
+ # Discover tools
196
+ await self._refresh_tools()
197
+ self._running = True
198
+ logger.info("[MCP:%s] Started with %d tools", self.name, len(self._tools))
199
+ return True
200
+
201
+ async def _refresh_tools(self):
202
+ try:
203
+ result = await self._request("tools/list")
204
+ self._tools = result.get("tools", []) if result else []
205
+ except Exception as exc:
206
+ logger.debug("[MCP:%s] tools/list failed: %s", self.name, exc)
207
+ self._tools = []
208
+
209
+ @property
210
+ def tools(self) -> List[Dict[str, Any]]:
211
+ return self._tools
212
+
213
+ async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
214
+ """Call a tool on this server. Returns {"success": bool, "result": …}."""
215
+ if not self._running:
216
+ return {"success": False, "error": f"MCP server {self.name!r} not running"}
217
+ # Strip server prefix if present (e.g. "quant_engine/backtest_strategy")
218
+ short_name = tool_name.split("/")[-1]
219
+ try:
220
+ result = await self._request("tools/call", {
221
+ "name": short_name,
222
+ "arguments": arguments,
223
+ })
224
+ if result is None:
225
+ return {"success": True, "result": None}
226
+ # MCP returns {content: [{type, text}]} or plain value
227
+ content = result.get("content", result)
228
+ if isinstance(content, list):
229
+ parts = [c.get("text", "") if isinstance(c, dict) else str(c) for c in content]
230
+ text = "\n".join(parts)
231
+ try:
232
+ parsed = json.loads(text)
233
+ return {"success": True, **parsed} if isinstance(parsed, dict) else {"success": True, "result": parsed}
234
+ except Exception:
235
+ return {"success": True, "result": text}
236
+ return {"success": True, "result": content}
237
+ except Exception as exc:
238
+ return {"success": False, "error": str(exc)}
239
+
240
+ async def stop(self):
241
+ self._running = False
242
+ if self._reader_task:
243
+ self._reader_task.cancel()
244
+ try:
245
+ await asyncio.wait_for(self._reader_task, timeout=1.0)
246
+ except Exception:
247
+ pass
248
+ self._reader_task = None
249
+ if self._proc:
250
+ try:
251
+ self._proc.stdin.close()
252
+ await asyncio.wait_for(self._proc.wait(), timeout=3.0)
253
+ except Exception:
254
+ try:
255
+ self._proc.kill()
256
+ except Exception:
257
+ pass
258
+ self._proc = None
259
+
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # MCPToolRegistry — manages multiple servers + tool registration
263
+ # ---------------------------------------------------------------------------
264
+
265
+ class MCPToolRegistry:
266
+ """
267
+ Loads MCP server config from ``~/.arthera/mcp_servers.json``, starts
268
+ each server, discovers tools, and registers them in the CLI's tool loop.
269
+
270
+ Tool naming convention: ``{server_name}/{tool_name}``
271
+ This avoids collisions when two servers expose a tool with the same name.
272
+ """
273
+
274
+ def __init__(self, config_path: pathlib.Path = MCP_CONFIG_PATH):
275
+ self.config_path = config_path
276
+ self._servers: Dict[str, MCPServer] = {}
277
+ self._tool_map: Dict[str, Tuple[str, str]] = {} # qualified_name → (server_name, tool_name)
278
+ self._loaded = False
279
+
280
+ # ── config ─────────────────────────────────────────────────────────────
281
+
282
+ def load_config(self) -> List[Dict[str, Any]]:
283
+ if not self.config_path.exists():
284
+ return []
285
+ try:
286
+ with open(self.config_path) as f:
287
+ data = json.load(f)
288
+ return data.get("servers", [])
289
+ except Exception as exc:
290
+ logger.warning("MCP config load failed: %s", exc)
291
+ return []
292
+
293
+ def save_example_config(self):
294
+ """Write an example mcp_servers.json if none exists."""
295
+ if self.config_path.exists():
296
+ return
297
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
298
+ example = {
299
+ "servers": [
300
+ {
301
+ "name": "quant_engine",
302
+ "command": "python3",
303
+ "args": ["path/to/mcp_server.py"],
304
+ "env": {"PYTHONPATH": "path/to/project"},
305
+ "description": "Your Arthera quant engine (backtest, signals, factors)"
306
+ }
307
+ ]
308
+ }
309
+ with open(self.config_path, "w") as f:
310
+ json.dump(example, f, indent=2, ensure_ascii=False)
311
+
312
+ # ── lifecycle ──────────────────────────────────────────────────────────
313
+
314
+ async def start_all(self) -> Dict[str, bool]:
315
+ """Start all configured MCP servers. Returns {name: started_ok}."""
316
+ server_configs = self.load_config()
317
+ results = {}
318
+ for cfg in server_configs:
319
+ name = cfg.get("name", "unnamed")
320
+ command = cfg.get("command", "")
321
+ args = cfg.get("args", [])
322
+ env = cfg.get("env", {})
323
+ desc = cfg.get("description", "")
324
+ if not command:
325
+ continue
326
+ if not cfg.get("enabled", True):
327
+ logger.debug("MCP server %r disabled in config, skipping", name)
328
+ continue
329
+ # expand ${VAR} placeholders in args and env values
330
+ def _expand(s: str) -> str:
331
+ import re as _re
332
+ return _re.sub(r'\$\{(\w+)\}', lambda m: os.environ.get(m.group(1), m.group(0)), s)
333
+ args = [_expand(a) for a in args]
334
+ env = {k: _expand(v) for k, v in env.items() if v}
335
+ srv = MCPServer(name=name, command=command, args=args, env=env, description=desc)
336
+ ok = await srv.start()
337
+ if ok:
338
+ self._servers[name] = srv
339
+ for tool in srv.tools:
340
+ qualified = f"{name}/{tool['name']}"
341
+ self._tool_map[qualified] = (name, tool["name"])
342
+ results[name] = ok
343
+ self._loaded = True
344
+ return results
345
+
346
+ async def stop_all(self):
347
+ for srv in self._servers.values():
348
+ await srv.stop()
349
+ self._servers.clear()
350
+ self._tool_map.clear()
351
+ self._loaded = False
352
+
353
+ # ── tool info ──────────────────────────────────────────────────────────
354
+
355
+ def all_tools(self) -> List[Dict[str, Any]]:
356
+ """Return all discovered tools with qualified names."""
357
+ result = []
358
+ for srv_name, srv in self._servers.items():
359
+ for tool in srv.tools:
360
+ t = dict(tool)
361
+ t["qualified_name"] = f"{srv_name}/{tool['name']}"
362
+ t["server"] = srv_name
363
+ result.append(t)
364
+ return result
365
+
366
+ # ── call ───────────────────────────────────────────────────────────────
367
+
368
+ async def call_tool(self, qualified_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
369
+ if "/" in qualified_name:
370
+ srv_name, tool_name = qualified_name.split("/", 1)
371
+ else:
372
+ # Try to find by short name
373
+ srv_name, tool_name = self._resolve_short_name(qualified_name)
374
+
375
+ srv = self._servers.get(srv_name)
376
+ if srv is None:
377
+ return {"success": False, "error": f"MCP server {srv_name!r} not found"}
378
+ return await srv.call_tool(tool_name, arguments)
379
+
380
+ def _resolve_short_name(self, name: str) -> Tuple[str, str]:
381
+ for qname, (srv, tool) in self._tool_map.items():
382
+ if tool == name:
383
+ return srv, tool
384
+ return "unknown", name
385
+
386
+ # ── registration ───────────────────────────────────────────────────────
387
+
388
+ def register_into(
389
+ self,
390
+ tool_registry: Dict,
391
+ schema_registry: List,
392
+ overwrite: bool = False,
393
+ ) -> int:
394
+ """
395
+ Add all MCP tools into the CLI's LOCAL_TOOLS and LOCAL_TOOL_SCHEMAS.
396
+
397
+ Tool names use the qualified ``server/tool`` format so they appear
398
+ in the model's tool list as e.g. ``quant_engine/backtest_strategy``.
399
+
400
+ Returns number of tools registered.
401
+ """
402
+ import asyncio as _asyncio
403
+
404
+ added = 0
405
+ existing_names = set(tool_registry.keys())
406
+
407
+ for srv_name, srv in self._servers.items():
408
+ for tool in srv.tools:
409
+ qname = f"{srv_name}/{tool['name']}"
410
+ if qname in existing_names and not overwrite:
411
+ continue
412
+
413
+ # Create a synchronous wrapper that runs the async call
414
+ def _make_sync_handler(qn: str) -> Callable:
415
+ def _handler(params: dict) -> dict:
416
+ loop = None
417
+ try:
418
+ loop = _asyncio.get_event_loop()
419
+ except RuntimeError:
420
+ pass
421
+ if loop and loop.is_running():
422
+ # We're inside an async context — schedule as coroutine
423
+ # The caller should use the async version instead
424
+ import concurrent.futures
425
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
426
+ fut = ex.submit(
427
+ _asyncio.run,
428
+ self.call_tool(qn, params)
429
+ )
430
+ return fut.result(timeout=60)
431
+ else:
432
+ return _asyncio.run(self.call_tool(qn, params))
433
+ return _handler
434
+
435
+ tool_registry[qname] = (
436
+ _make_sync_handler(qname),
437
+ tool.get("description", f"MCP tool from {srv_name}"),
438
+ )
439
+ added += 1
440
+
441
+ # Build schema
442
+ input_schema = tool.get("inputSchema") or tool.get("parameters") or {
443
+ "type": "object", "properties": {}, "required": []
444
+ }
445
+ schema_registry.append({
446
+ "type": "function",
447
+ "function": {
448
+ "name": qname,
449
+ "description": f"[{srv_name}] {tool.get('description', '')}",
450
+ "parameters": input_schema,
451
+ },
452
+ })
453
+
454
+ return added
455
+
456
+ # ── async call tool (for use inside async context) ─────────────────────
457
+
458
+ async def call_tool_async(
459
+ self,
460
+ qualified_name: str,
461
+ arguments: Dict[str, Any],
462
+ ) -> Dict[str, Any]:
463
+ return await self.call_tool(qualified_name, arguments)
464
+
465
+ # ── status ─────────────────────────────────────────────────────────────
466
+
467
+ def status(self) -> List[Dict[str, Any]]:
468
+ return [
469
+ {
470
+ "name": name,
471
+ "running": srv._running,
472
+ "tool_count": len(srv.tools),
473
+ "description": srv.description,
474
+ "tools": [t["name"] for t in srv.tools],
475
+ }
476
+ for name, srv in self._servers.items()
477
+ ]
478
+
479
+
480
+ # ---------------------------------------------------------------------------
481
+ # Module-level singleton (lazy-initialized)
482
+ # ---------------------------------------------------------------------------
483
+
484
+ _registry: Optional[MCPToolRegistry] = None
485
+
486
+
487
+ def get_registry() -> MCPToolRegistry:
488
+ global _registry
489
+ if _registry is None:
490
+ _registry = MCPToolRegistry()
491
+ return _registry
492
+
493
+
494
+ async def init_mcp(tool_registry: Dict, schema_registry: List) -> Dict[str, bool]:
495
+ """
496
+ Convenience function: load config, start all servers, register tools.
497
+ Call once at startup.
498
+
499
+ Returns {server_name: started_ok} dict.
500
+ """
501
+ reg = get_registry()
502
+ results = await reg.start_all()
503
+ n = reg.register_into(tool_registry, schema_registry)
504
+ if n > 0:
505
+ logger.info("MCP: registered %d tools from %d servers", n, len(results))
506
+ return results