Переглянути джерело

crypto-mcp and news-mcp added

Lukas Goldschmidt 1 місяць тому
батько
коміт
d8fe0598e5

+ 23 - 0
src/trader_mcp/crypto_client.py

@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+from typing import Any
+
+from .mcp_client import GenericMcpClient, McpSseConfig
+
+CRYPTO_MCP_SSE_URL = "http://192.168.0.200:8505/mcp/sse"
+
+_crypto = GenericMcpClient(McpSseConfig(base_url=CRYPTO_MCP_SSE_URL), timeout=30)
+
+
+def get_latest_xrp_price(symbol: str = "xrp") -> dict[str, Any]:
+    """Calls crypto-mcp.get_price and returns the raw JSON payload."""
+    payload = _crypto.call_tool("get_price", {"symbol": symbol})
+
+    if isinstance(payload, dict):
+        return payload
+
+    # Normalize list-wrapped / content-wrapped results.
+    if isinstance(payload, list) and payload and isinstance(payload[0], dict):
+        return payload[0]
+
+    raise TypeError(f"Unexpected crypto-mcp get_price payload type: {type(payload)}")

+ 45 - 7
src/trader_mcp/dashboard.py

@@ -1,29 +1,67 @@
 from fastapi import APIRouter
 from fastapi.responses import HTMLResponse
 
+from .exec_client import list_accounts
+
 router = APIRouter(prefix="/dashboard", tags=["dashboard"])
 
 
 @router.get("/", response_class=HTMLResponse)
 def dashboard_home():
-    return """<!doctype html>
+    accounts = list_accounts()
+
+    rows = "".join(
+        """
+        <tr>
+          <td>{display_name}</td>
+          <td>{venue}</td>
+          <td>{venue_account_ref}</td>
+          <td>{description}</td>
+          <td>{enabled}</td>
+        </tr>
+        """.strip().format(
+            display_name=(a.get("display_name") or "") if isinstance(a, dict) else "",
+            venue=(a.get("venue") or "") if isinstance(a, dict) else "",
+            venue_account_ref=(a.get("venue_account_ref") or "") if isinstance(a, dict) else "",
+            description=(a.get("description") or "") if isinstance(a, dict) else "",
+            enabled=("yes" if (a.get("enabled") if isinstance(a, dict) else False) else "no"),
+        )
+        for a in (accounts or [])
+    )
+
+    return f"""<!doctype html>
 <html>
   <head>
     <meta charset=\"utf-8\" />
     <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
     <title>Trader MCP Dashboard</title>
     <style>
-      body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; margin: 2rem; }
-      .card { max-width: 720px; padding: 1.25rem; border: 1px solid #e5e7eb; border-radius: 12px; }
-      code { background: #f3f4f6; padding: 0.15rem 0.35rem; border-radius: 8px; }
-      .muted { color: #6b7280; }
+      body {{ font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; margin: 2rem; color: #111827; }}
+      .card {{ max-width: 980px; padding: 1.25rem; border: 1px solid #e5e7eb; border-radius: 12px; }}
+      .muted {{ color: #6b7280; }}
+      table {{ width: 100%; border-collapse: collapse; margin-top: 14px; }}
+      th, td {{ border-bottom: 1px solid #e5e7eb; padding: 10px 8px; text-align: left; vertical-align: top; }}
+      th {{ background: #f9fafb; }}
+      .pill {{ display:inline-block; padding:2px 10px; border-radius:999px; background:#f3f4f6; font-size: 0.9em; }}
     </style>
   </head>
   <body>
     <div class=\"card\">
       <h1>Trader MCP Dashboard</h1>
-      <p class=\"muted\">Scaffold page. Connect your trading widgets to MCP routes when you’re ready.</p>
-      <p>Current: <code>GET /dashboard/</code></p>
+      <p class=\"muted\">exec-mcp accounts</p>
+
+      <table>
+        <tr>
+          <th>name</th>
+          <th>venue</th>
+          <th>exchange account ref</th>
+          <th>description</th>
+          <th>enabled</th>
+        </tr>
+        {rows}
+      </table>
+
+      <p class=\"muted\" style=\"margin-top: 12px;\">Source: <span class=\"pill\">exec-mcp.list_accounts</span></p>
     </div>
   </body>
 </html>"""

+ 27 - 0
src/trader_mcp/exec_client.py

@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+from typing import Any
+
+from .mcp_client import GenericMcpClient, McpSseConfig
+
+
+EXEC_MCP_SSE_URL = "http://192.168.0.249:8560/mcp/sse"
+
+
+_mcp = GenericMcpClient(McpSseConfig(base_url=EXEC_MCP_SSE_URL), timeout=30)
+
+
+def list_accounts() -> list[dict[str, Any]]:
+    """Call exec-mcp.list_accounts via MCP and return its result."""
+
+    payload = _mcp.call_tool("list_accounts", {})
+
+    if isinstance(payload, list):
+        return payload
+    if isinstance(payload, dict) and "accounts" in payload and isinstance(payload["accounts"], list):
+        return payload["accounts"]
+    if isinstance(payload, dict) and "result" in payload and isinstance(payload["result"], list):
+        return payload["result"]
+    if payload is None:
+        return []
+    return [payload]

+ 59 - 0
src/trader_mcp/mcp_client.py

@@ -0,0 +1,59 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Optional
+
+import anyio
+from fastmcp.client import Client
+from fastmcp.client.transports.sse import SSETransport
+
+
+@dataclass(frozen=True)
+class McpSseConfig:
+    base_url: str  # e.g. http://host:port/mcp/sse
+
+
+class GenericMcpClient:
+    def __init__(
+        self,
+        sse: McpSseConfig,
+        *,
+        timeout: int | float = 30,
+    ) -> None:
+        self._sse = sse
+        self._timeout = timeout
+
+    def _build_client(self) -> Client:
+        transport = SSETransport(self._sse.base_url)
+        return Client(transport, auto_initialize=True, timeout=self._timeout)
+
+    async def _call_tool_async(
+        self,
+        tool_name: str,
+        arguments: Optional[dict[str, Any]] = None,
+    ) -> Any:
+        async with self._build_client() as client:
+            result = await client.call_tool(tool_name, arguments or {})
+
+            # FastMCP may already return parsed structured content.
+            if hasattr(result, "structuredContent") and result.structuredContent is not None:
+                return result.structuredContent
+
+            payload = getattr(result, "content", None)
+            if hasattr(payload, "text"):
+                import json
+
+                payload = json.loads(payload.text)
+
+            if payload is None:
+                payload = result
+
+            if isinstance(payload, list) and payload and hasattr(payload[0], "text"):
+                import json
+
+                payload = json.loads(payload[0].text)
+
+            return payload
+
+    def call_tool(self, tool_name: str, arguments: Optional[dict[str, Any]] = None) -> Any:
+        return anyio.run(self._call_tool_async, tool_name, arguments)

+ 18 - 0
src/trader_mcp/news_client.py

@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+from typing import Any
+
+from .mcp_client import GenericMcpClient, McpSseConfig
+
+NEWS_MCP_SSE_URL = "http://192.168.0.200:8506/mcp/sse"
+
+_news = GenericMcpClient(McpSseConfig(base_url=NEWS_MCP_SSE_URL), timeout=30)
+
+
+def get_news_sentiment(entity: str = "XRP", timeframe: str = "24h") -> dict[str, Any]:
+    payload = _news.call_tool("get_news_sentiment", {"entity": entity, "timeframe": timeframe})
+    if isinstance(payload, dict):
+        return payload
+    if isinstance(payload, list) and payload and isinstance(payload[0], dict):
+        return payload[0]
+    raise TypeError(f"Unexpected news-mcp get_news_sentiment payload type: {type(payload)}")

+ 22 - 0
tests/test_crypto_client.py

@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+import pytest
+
+
+def test_exec_list_accounts_returns_non_empty_json():
+    # already covered in test_smoke
+    from src.trader_mcp.exec_client import list_accounts
+
+    accounts = list_accounts()
+    assert isinstance(accounts, list)
+    assert len(accounts) > 0
+
+
+def test_crypto_get_latest_xrp_price_shape():
+    from src.trader_mcp.crypto_client import get_latest_xrp_price
+
+    resp = get_latest_xrp_price("xrp")
+    assert isinstance(resp, dict)
+    assert resp.get("symbol") in {"xrp", "XRP"}
+    assert isinstance(resp.get("price"), (int, float))
+    assert isinstance(resp.get("timestamp"), (int, float))

+ 12 - 0
tests/test_news_client.py

@@ -0,0 +1,12 @@
+from __future__ import annotations
+
+
+def test_news_get_xrp_sentiment_shape():
+    from src.trader_mcp.news_client import get_news_sentiment
+
+    resp = get_news_sentiment("XRP")
+    assert isinstance(resp, dict)
+    assert resp.get("entity") == "XRP"
+    assert resp.get("sentiment") in {"positive", "neutral", "negative"}
+    assert isinstance(resp.get("score"), (int, float))
+    assert isinstance(resp.get("cluster_count"), (int, float))

+ 11 - 4
tests/test_smoke.py

@@ -26,7 +26,14 @@ def test_health(client):
     assert r.json().get("status") == "ok"
 
 
-def test_dashboard(client):
-    r = client.get("/dashboard/")
-    assert r.status_code == 200
-    assert "Trader MCP Dashboard" in r.text
+def test_exec_list_accounts_returns_non_empty_json():
+    # Prove we can fetch accounts from exec-mcp.
+    from src.trader_mcp.exec_client import list_accounts
+
+    accounts = list_accounts()
+    assert isinstance(accounts, list)
+    assert len(accounts) > 0
+    # Spot-check expected fields.
+    first = accounts[0]
+    assert isinstance(first, dict)
+    assert "id" in first