|
|
@@ -0,0 +1,394 @@
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+from contextlib import asynccontextmanager
|
|
|
+from datetime import datetime, timezone
|
|
|
+from uuid import uuid4
|
|
|
+
|
|
|
+from fastapi import FastAPI, Form, HTTPException
|
|
|
+from fastapi.responses import HTMLResponse, RedirectResponse
|
|
|
+from fastmcp import FastMCP
|
|
|
+from pydantic import BaseModel, Field
|
|
|
+
|
|
|
+from .storage import get_connection, init_db
|
|
|
+
|
|
|
+# Public MCP surface stays read-only.
|
|
|
+mcp = FastMCP("exec-mcp")
|
|
|
+
|
|
|
+
|
|
|
+@asynccontextmanager
|
|
|
+async def lifespan(_: FastAPI):
|
|
|
+ # Initialize the local SQLite scaffold on startup.
|
|
|
+ init_db()
|
|
|
+ yield
|
|
|
+
|
|
|
+
|
|
|
+app = FastAPI(title="exec-mcp", lifespan=lifespan)
|
|
|
+SUPPORTED_VENUES = {"bitstamp"}
|
|
|
+
|
|
|
+
|
|
|
+class AccountView(BaseModel):
|
|
|
+ display_name: str
|
|
|
+ venue: str
|
|
|
+ venue_account_ref: str
|
|
|
+ description: str | None = None
|
|
|
+ enabled: bool
|
|
|
+ metadata: str = Field(default="{}")
|
|
|
+ created_at: str | None = None
|
|
|
+ updated_at: str | None = None
|
|
|
+
|
|
|
+
|
|
|
+@app.get("/", response_class=HTMLResponse)
|
|
|
+def http_root() -> str:
|
|
|
+ return """
|
|
|
+ <html><body style="font-family: system-ui, sans-serif; max-width: 900px; margin: 32px auto; padding: 0 16px;">
|
|
|
+ <header style="margin-bottom: 24px;">
|
|
|
+ <h1>exec-mcp</h1>
|
|
|
+ </header>
|
|
|
+ <main>
|
|
|
+ <p>Execution layer for Trader27, with a dashboard for account management.</p>
|
|
|
+ <ul>
|
|
|
+ <li><a href="/dashboard">Dashboard</a></li>
|
|
|
+ <li><a href="/health">/health</a></li>
|
|
|
+ </ul>
|
|
|
+ </main>
|
|
|
+ <footer style="margin-top: 32px; padding-top: 12px; border-top: 1px solid #e5e7eb; color: #6b7280;">
|
|
|
+ exec-mcp
|
|
|
+ </footer>
|
|
|
+ </body></html>
|
|
|
+ """
|
|
|
+
|
|
|
+
|
|
|
+@app.get("/health")
|
|
|
+def http_health() -> dict:
|
|
|
+ return {"ok": True, "server": "exec-mcp"}
|
|
|
+
|
|
|
+
|
|
|
+@app.get("/dashboard", response_class=HTMLResponse)
|
|
|
+def http_dashboard() -> str:
|
|
|
+ rows = list_accounts()
|
|
|
+ options = "".join(f'<option value="{v}">{v}</option>' for v in sorted(SUPPORTED_VENUES))
|
|
|
+ table_rows = "".join(
|
|
|
+ f"""
|
|
|
+ <tr>
|
|
|
+ <td>{row['display_name']}</td>
|
|
|
+ <td>{row['venue']}</td>
|
|
|
+ <td>{row['venue_account_ref']}</td>
|
|
|
+ <td>{row['description'] or ''}</td>
|
|
|
+ <td>{'yes' if row['enabled'] else 'no'}</td>
|
|
|
+ <td class="actions">
|
|
|
+ <form method="get" action="/dashboard/accounts/{row['venue']}/{row['venue_account_ref']}/edit">
|
|
|
+ <button type="submit">Edit</button>
|
|
|
+ </form>
|
|
|
+ <form method="post" action="/dashboard/accounts/{row['venue']}/{row['venue_account_ref']}/delete">
|
|
|
+ <button type="submit" class="danger">Delete</button>
|
|
|
+ </form>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ """
|
|
|
+ for row in rows
|
|
|
+ )
|
|
|
+ return f"""
|
|
|
+ <html>
|
|
|
+ <head>
|
|
|
+ <style>
|
|
|
+ body {{ font-family: system-ui, sans-serif; max-width: 1100px; margin: 32px auto; padding: 0 16px; color: #1f2937; }}
|
|
|
+ header, footer {{ padding: 12px 0; }}
|
|
|
+ header {{ border-bottom: 1px solid #e5e7eb; margin-bottom: 20px; }}
|
|
|
+ footer {{ border-top: 1px solid #e5e7eb; margin-top: 32px; color: #6b7280; }}
|
|
|
+ .panel {{ background: #f8fafc; border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; margin-bottom: 20px; }}
|
|
|
+ form {{ display: grid; gap: 10px; max-width: 520px; }}
|
|
|
+ input, select, button {{ padding: 10px 12px; border-radius: 8px; border: 1px solid #cbd5e1; }}
|
|
|
+ button {{ background: #111827; color: white; cursor: pointer; }}
|
|
|
+ button.danger {{ background: #991b1b; }}
|
|
|
+ table {{ width: 100%; border-collapse: collapse; margin-top: 12px; }}
|
|
|
+ th, td {{ border-bottom: 1px solid #e5e7eb; padding: 10px 8px; text-align: left; vertical-align: top; }}
|
|
|
+ .actions {{ display: flex; gap: 8px; align-items: center; }}
|
|
|
+ .actions form {{ display: inline; }}
|
|
|
+ .actions a {{ color: #1d4ed8; text-decoration: none; }}
|
|
|
+ </style>
|
|
|
+ </head>
|
|
|
+ <body>
|
|
|
+ <header>
|
|
|
+ <h1>exec-mcp dashboard</h1>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ <div class="panel">
|
|
|
+ <h2>Create account</h2>
|
|
|
+ <form method="post" action="/dashboard/accounts/create">
|
|
|
+ <input name="display_name" placeholder="name" required />
|
|
|
+ <select name="venue">{options}</select>
|
|
|
+ <input name="venue_account_ref" placeholder="exchange account ref" required />
|
|
|
+ <input name="api_key" placeholder="api key" required />
|
|
|
+ <input name="api_secret" placeholder="api secret" required />
|
|
|
+ <input name="description" placeholder="description" />
|
|
|
+ <label><input type="checkbox" name="enabled" checked /> enabled</label>
|
|
|
+ <button type="submit">Create</button>
|
|
|
+ </form>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="panel">
|
|
|
+ <h2>Accounts</h2>
|
|
|
+ <table>
|
|
|
+ <tr>
|
|
|
+ <th>name</th><th>venue</th><th>exchange account ref</th><th>description</th><th>enabled</th><th>actions</th>
|
|
|
+ </tr>
|
|
|
+ {table_rows}
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <footer>
|
|
|
+ exec-mcp
|
|
|
+ </footer>
|
|
|
+ </body>
|
|
|
+ </html>
|
|
|
+ """
|
|
|
+
|
|
|
+
|
|
|
+@app.post("/dashboard/accounts/create")
|
|
|
+def http_dashboard_create_account(
|
|
|
+ display_name: str = Form(...),
|
|
|
+ venue: str = Form(...),
|
|
|
+ venue_account_ref: str = Form(...),
|
|
|
+ api_key: str = Form(...),
|
|
|
+ api_secret: str = Form(...),
|
|
|
+ description: str | None = Form(None),
|
|
|
+ enabled: bool = Form(False),
|
|
|
+) -> RedirectResponse:
|
|
|
+ create_account(
|
|
|
+ display_name=display_name,
|
|
|
+ venue=venue,
|
|
|
+ venue_account_ref=venue_account_ref,
|
|
|
+ api_key=api_key,
|
|
|
+ api_secret=api_secret,
|
|
|
+ description=description,
|
|
|
+ enabled=enabled,
|
|
|
+ )
|
|
|
+ return RedirectResponse(url="/dashboard", status_code=303)
|
|
|
+
|
|
|
+
|
|
|
+@app.get("/dashboard/accounts/{venue}/{venue_account_ref}/edit", response_class=HTMLResponse)
|
|
|
+def http_dashboard_edit_account(venue: str, venue_account_ref: str) -> str:
|
|
|
+ with get_connection() as conn:
|
|
|
+ row = conn.execute(
|
|
|
+ "SELECT display_name, venue, venue_account_ref, description, enabled FROM accounts WHERE venue = ? AND venue_account_ref = ?",
|
|
|
+ (venue, venue_account_ref),
|
|
|
+ ).fetchone()
|
|
|
+ if row is None:
|
|
|
+ raise HTTPException(status_code=404, detail="account not found")
|
|
|
+ checked = "checked" if row["enabled"] else ""
|
|
|
+ return f"""
|
|
|
+ <html>
|
|
|
+ <body style="font-family: system-ui, sans-serif; max-width: 700px; margin: 32px auto; padding: 0 16px;">
|
|
|
+ <h1>Edit account</h1>
|
|
|
+ <p><a href="/dashboard">Back to dashboard</a></p>
|
|
|
+ <form method="post" action="/dashboard/accounts/{venue}/{venue_account_ref}/update" style="display:grid; gap:10px; max-width: 520px;">
|
|
|
+ <input name="display_name" value="{row['display_name']}" placeholder="name" required />
|
|
|
+ <input value="{row['venue_account_ref']}" placeholder="exchange account ref" readonly />
|
|
|
+ <input name="description" value="{row['description'] or ''}" placeholder="description" />
|
|
|
+ <label><input type="checkbox" name="enabled" {checked} /> enabled</label>
|
|
|
+ <button type="submit">Save</button>
|
|
|
+ </form>
|
|
|
+ </body>
|
|
|
+ </html>
|
|
|
+ """
|
|
|
+
|
|
|
+
|
|
|
+@app.post("/dashboard/accounts/{venue}/{venue_account_ref}/update")
|
|
|
+def http_dashboard_update_account(
|
|
|
+ venue: str,
|
|
|
+ venue_account_ref: str,
|
|
|
+ display_name: str = Form(...),
|
|
|
+ description: str = Form(""),
|
|
|
+ enabled: bool = Form(False),
|
|
|
+) -> RedirectResponse:
|
|
|
+ update_account(venue=venue, venue_account_ref=venue_account_ref, display_name=display_name, description=description or None, enabled=enabled)
|
|
|
+ return RedirectResponse(url="/dashboard", status_code=303)
|
|
|
+
|
|
|
+
|
|
|
+@app.post("/dashboard/accounts/{venue}/{venue_account_ref}/delete")
|
|
|
+def http_dashboard_delete_account(venue: str, venue_account_ref: str) -> RedirectResponse:
|
|
|
+ delete_account(venue=venue, venue_account_ref=venue_account_ref)
|
|
|
+ return RedirectResponse(url="/dashboard", status_code=303)
|
|
|
+
|
|
|
+
|
|
|
+@mcp.tool()
|
|
|
+def list_accounts(venue: str | None = None) -> list[dict]:
|
|
|
+ query = "SELECT display_name, venue, venue_account_ref, description, enabled, metadata_json, created_at, updated_at FROM accounts"
|
|
|
+ params: tuple = ()
|
|
|
+ if venue:
|
|
|
+ query += " WHERE venue = ?"
|
|
|
+ params = (venue,)
|
|
|
+ query += " ORDER BY display_name ASC"
|
|
|
+
|
|
|
+ with get_connection() as conn:
|
|
|
+ rows = conn.execute(query, params).fetchall()
|
|
|
+
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ "display_name": row["display_name"],
|
|
|
+ "venue": row["venue"],
|
|
|
+ "venue_account_ref": row["venue_account_ref"],
|
|
|
+ "description": row["description"],
|
|
|
+ "enabled": bool(row["enabled"]),
|
|
|
+ "metadata": row["metadata_json"],
|
|
|
+ "created_at": row["created_at"],
|
|
|
+ "updated_at": row["updated_at"],
|
|
|
+ }
|
|
|
+ for row in rows
|
|
|
+ ]
|
|
|
+
|
|
|
+
|
|
|
+@mcp.tool()
|
|
|
+def get_account_info(venue: str, venue_account_ref: str) -> dict:
|
|
|
+ with get_connection() as conn:
|
|
|
+ account = conn.execute(
|
|
|
+ "SELECT id, display_name, venue, venue_account_ref, description, enabled, metadata_json FROM accounts WHERE venue = ? AND venue_account_ref = ?",
|
|
|
+ (venue, venue_account_ref),
|
|
|
+ ).fetchone()
|
|
|
+ secrets = None
|
|
|
+ if account is not None:
|
|
|
+ secrets = conn.execute(
|
|
|
+ "SELECT api_key, api_secret FROM account_secrets WHERE account_id = ?",
|
|
|
+ (account["id"],),
|
|
|
+ ).fetchone()
|
|
|
+
|
|
|
+ if account is None:
|
|
|
+ raise HTTPException(status_code=404, detail="account not found")
|
|
|
+
|
|
|
+ # Do not expose the hidden internal primary key or raw secret material.
|
|
|
+ return {
|
|
|
+ "display_name": account["display_name"],
|
|
|
+ "venue": account["venue"],
|
|
|
+ "venue_account_ref": account["venue_account_ref"],
|
|
|
+ "description": account["description"],
|
|
|
+ "enabled": bool(account["enabled"]),
|
|
|
+ "metadata": account["metadata_json"],
|
|
|
+ "api_key_present": secrets is not None,
|
|
|
+ "api_secret_present": secrets is not None,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def create_account(
|
|
|
+ display_name: str,
|
|
|
+ venue: str,
|
|
|
+ venue_account_ref: str,
|
|
|
+ api_key: str,
|
|
|
+ api_secret: str,
|
|
|
+ description: str | None = None,
|
|
|
+ enabled: bool = True,
|
|
|
+) -> dict:
|
|
|
+ if venue not in SUPPORTED_VENUES:
|
|
|
+ raise HTTPException(status_code=400, detail="unsupported venue")
|
|
|
+
|
|
|
+ account_pk = str(uuid4())
|
|
|
+ now = datetime.now(timezone.utc).isoformat()
|
|
|
+ with get_connection() as conn:
|
|
|
+ conn.execute(
|
|
|
+ """
|
|
|
+ INSERT INTO accounts (id, display_name, venue, venue_account_ref, description, enabled, created_at, updated_at)
|
|
|
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
|
+ ON CONFLICT(venue, venue_account_ref) DO UPDATE SET
|
|
|
+ display_name=excluded.display_name,
|
|
|
+ description=excluded.description,
|
|
|
+ enabled=excluded.enabled,
|
|
|
+ updated_at=excluded.updated_at
|
|
|
+ """,
|
|
|
+ (account_pk, display_name, venue, venue_account_ref, description, int(enabled), now, now),
|
|
|
+ )
|
|
|
+ account = conn.execute(
|
|
|
+ "SELECT id FROM accounts WHERE venue = ? AND venue_account_ref = ?",
|
|
|
+ (venue, venue_account_ref),
|
|
|
+ ).fetchone()
|
|
|
+ conn.execute(
|
|
|
+ """
|
|
|
+ INSERT INTO account_secrets (account_id, api_key, api_secret, created_at, updated_at)
|
|
|
+ VALUES (?, ?, ?, ?, ?)
|
|
|
+ ON CONFLICT(account_id) DO UPDATE SET
|
|
|
+ api_key=excluded.api_key,
|
|
|
+ api_secret=excluded.api_secret,
|
|
|
+ updated_at=excluded.updated_at
|
|
|
+ """,
|
|
|
+ (account["id"], api_key, api_secret, now, now),
|
|
|
+ )
|
|
|
+ conn.commit()
|
|
|
+
|
|
|
+ return {
|
|
|
+ "display_name": display_name,
|
|
|
+ "venue": venue,
|
|
|
+ "venue_account_ref": venue_account_ref,
|
|
|
+ "enabled": enabled,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def update_account(
|
|
|
+ venue: str,
|
|
|
+ venue_account_ref: str,
|
|
|
+ display_name: str | None = None,
|
|
|
+ description: str | None = None,
|
|
|
+ enabled: bool | None = None,
|
|
|
+) -> dict:
|
|
|
+ now = datetime.now(timezone.utc).isoformat()
|
|
|
+ with get_connection() as conn:
|
|
|
+ row = conn.execute(
|
|
|
+ "SELECT id FROM accounts WHERE venue = ? AND venue_account_ref = ?",
|
|
|
+ (venue, venue_account_ref),
|
|
|
+ ).fetchone()
|
|
|
+ if row is None:
|
|
|
+ raise HTTPException(status_code=404, detail="account not found")
|
|
|
+ if display_name is not None:
|
|
|
+ conn.execute(
|
|
|
+ "UPDATE accounts SET display_name = ?, updated_at = ? WHERE id = ?",
|
|
|
+ (display_name, now, row["id"]),
|
|
|
+ )
|
|
|
+ if description is not None:
|
|
|
+ conn.execute(
|
|
|
+ "UPDATE accounts SET description = ?, updated_at = ? WHERE id = ?",
|
|
|
+ (description, now, row["id"]),
|
|
|
+ )
|
|
|
+ if enabled is not None:
|
|
|
+ conn.execute(
|
|
|
+ "UPDATE accounts SET enabled = ?, updated_at = ? WHERE id = ?",
|
|
|
+ (int(enabled), now, row["id"]),
|
|
|
+ )
|
|
|
+ conn.commit()
|
|
|
+ return {"venue": venue, "venue_account_ref": venue_account_ref, "updated": True}
|
|
|
+
|
|
|
+
|
|
|
+def delete_account(venue: str, venue_account_ref: str) -> dict:
|
|
|
+ with get_connection() as conn:
|
|
|
+ deleted = conn.execute(
|
|
|
+ "DELETE FROM accounts WHERE venue = ? AND venue_account_ref = ?",
|
|
|
+ (venue, venue_account_ref),
|
|
|
+ ).rowcount
|
|
|
+ conn.commit()
|
|
|
+ if not deleted:
|
|
|
+ raise HTTPException(status_code=404, detail="account not found")
|
|
|
+ return {"venue": venue, "venue_account_ref": venue_account_ref, "deleted": True}
|
|
|
+
|
|
|
+
|
|
|
+def record_balance(venue: str, venue_account_ref: str, asset_code: str, balance_value: float) -> dict:
|
|
|
+ # Internal helper, called by execution sync logic when balances change.
|
|
|
+ now = datetime.now(timezone.utc).isoformat()
|
|
|
+ with get_connection() as conn:
|
|
|
+ account = conn.execute(
|
|
|
+ "SELECT id FROM accounts WHERE venue = ? AND venue_account_ref = ?",
|
|
|
+ (venue, venue_account_ref),
|
|
|
+ ).fetchone()
|
|
|
+ if account is None:
|
|
|
+ raise HTTPException(status_code=404, detail="account not found")
|
|
|
+ conn.execute(
|
|
|
+ "INSERT INTO balance_snapshots (account_id, asset_code, balance_value, captured_at) VALUES (?, ?, ?, ?)",
|
|
|
+ (account["id"], asset_code, balance_value, now),
|
|
|
+ )
|
|
|
+ conn.commit()
|
|
|
+ return {
|
|
|
+ "venue": venue,
|
|
|
+ "venue_account_ref": venue_account_ref,
|
|
|
+ "asset_code": asset_code,
|
|
|
+ "balance_value": balance_value,
|
|
|
+ "captured_at": now,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def main() -> None:
|
|
|
+ init_db()
|