|
|
@@ -1,24 +1,21 @@
|
|
|
from __future__ import annotations
|
|
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
-from datetime import datetime, timezone
|
|
|
-from sqlite3 import IntegrityError
|
|
|
-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
|
|
|
+from .models import AccountView
|
|
|
+from . import repo
|
|
|
+from .services_bitstamp import fetch_account_info as fetch_remote_account_info
|
|
|
+from .storage import 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
|
|
|
|
|
|
@@ -27,18 +24,6 @@ app = FastAPI(title="exec-mcp", lifespan=lifespan)
|
|
|
SUPPORTED_VENUES = {"bitstamp"}
|
|
|
|
|
|
|
|
|
-class AccountView(BaseModel):
|
|
|
- id: str
|
|
|
- display_name: str | None = None
|
|
|
- venue: str | None = None
|
|
|
- venue_account_ref: str | None = None
|
|
|
- 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 """
|
|
|
@@ -67,7 +52,7 @@ def http_health() -> dict:
|
|
|
|
|
|
@app.get("/dashboard", response_class=HTMLResponse)
|
|
|
def http_dashboard() -> str:
|
|
|
- rows = list_accounts()
|
|
|
+ rows = list_accounts(enabled_only=False)
|
|
|
options = "".join(f'<option value="{v}">{v}</option>' for v in sorted(SUPPORTED_VENUES))
|
|
|
table_rows = "".join(
|
|
|
f"""
|
|
|
@@ -157,7 +142,7 @@ def http_dashboard_create_account(
|
|
|
description: str | None = Form(None),
|
|
|
enabled: bool = Form(False),
|
|
|
) -> RedirectResponse:
|
|
|
- create_account(
|
|
|
+ repo.create_account(
|
|
|
display_name=display_name,
|
|
|
venue=venue,
|
|
|
venue_account_ref=venue_account_ref,
|
|
|
@@ -171,13 +156,7 @@ def http_dashboard_create_account(
|
|
|
|
|
|
@app.get("/dashboard/accounts/{account_id}/edit", response_class=HTMLResponse)
|
|
|
def http_dashboard_edit_account(account_id: str) -> str:
|
|
|
- with get_connection() as conn:
|
|
|
- row = conn.execute(
|
|
|
- "SELECT id, display_name, venue, venue_account_ref, description, enabled FROM accounts WHERE id = ?",
|
|
|
- (account_id,),
|
|
|
- ).fetchone()
|
|
|
- if row is None:
|
|
|
- raise HTTPException(status_code=404, detail="account not found")
|
|
|
+ row = repo.get_account(account_id)
|
|
|
checked = "checked" if row["enabled"] else ""
|
|
|
return f"""
|
|
|
<html>
|
|
|
@@ -205,157 +184,27 @@ def http_dashboard_update_account(
|
|
|
description: str = Form(""),
|
|
|
enabled: bool = Form(False),
|
|
|
) -> RedirectResponse:
|
|
|
- update_account(account_id=account_id, display_name=display_name, description=description or None, enabled=enabled)
|
|
|
+ repo.update_account(account_id=account_id, display_name=display_name, description=description or None, enabled=enabled)
|
|
|
return RedirectResponse(url="/dashboard", status_code=303)
|
|
|
|
|
|
|
|
|
@app.post("/dashboard/accounts/{account_id}/delete")
|
|
|
def http_dashboard_delete_account(account_id: str) -> RedirectResponse:
|
|
|
- delete_account(account_id=account_id)
|
|
|
+ repo.delete_account(account_id=account_id)
|
|
|
return RedirectResponse(url="/dashboard", status_code=303)
|
|
|
|
|
|
|
|
|
@mcp.tool()
|
|
|
-def list_accounts(venue: str | None = None) -> list[dict]:
|
|
|
- query = "SELECT id, 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 created_at ASC"
|
|
|
-
|
|
|
- with get_connection() as conn:
|
|
|
- rows = conn.execute(query, params).fetchall()
|
|
|
-
|
|
|
- return [
|
|
|
- {
|
|
|
- "id": row["id"],
|
|
|
- "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
|
|
|
- ]
|
|
|
+def list_accounts(enabled_only: bool = True, venue: str | None = None) -> list[dict]:
|
|
|
+ return repo.list_accounts(venue=venue, enabled_only=enabled_only)
|
|
|
|
|
|
|
|
|
@mcp.tool()
|
|
|
def get_account_info(account_id: 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 id = ?",
|
|
|
- (account_id,),
|
|
|
- ).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")
|
|
|
-
|
|
|
- return {
|
|
|
- "id": account["id"],
|
|
|
- "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:
|
|
|
- try:
|
|
|
- conn.execute(
|
|
|
- """
|
|
|
- INSERT INTO accounts (id, display_name, venue, venue_account_ref, description, enabled, created_at, updated_at)
|
|
|
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
|
- """,
|
|
|
- (account_pk, display_name, venue, venue_account_ref, description, int(enabled), now, now),
|
|
|
- )
|
|
|
- conn.execute(
|
|
|
- """
|
|
|
- INSERT INTO account_secrets (account_id, api_key, api_secret, created_at, updated_at)
|
|
|
- VALUES (?, ?, ?, ?, ?)
|
|
|
- """,
|
|
|
- (account_pk, api_key, api_secret, now, now),
|
|
|
- )
|
|
|
- conn.commit()
|
|
|
- except IntegrityError as exc:
|
|
|
- conn.rollback()
|
|
|
- if "api_key" in str(exc).lower():
|
|
|
- raise HTTPException(status_code=409, detail="api key already exists") from exc
|
|
|
- raise
|
|
|
-
|
|
|
- return {"id": account_pk, "display_name": display_name, "venue": venue}
|
|
|
-
|
|
|
-
|
|
|
-def update_account(
|
|
|
- account_id: 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 id = ?", (account_id,)).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, account_id))
|
|
|
- if description is not None:
|
|
|
- conn.execute("UPDATE accounts SET description = ?, updated_at = ? WHERE id = ?", (description, now, account_id))
|
|
|
- if enabled is not None:
|
|
|
- conn.execute("UPDATE accounts SET enabled = ?, updated_at = ? WHERE id = ?", (int(enabled), now, account_id))
|
|
|
- conn.commit()
|
|
|
- return {"id": account_id, "updated": True}
|
|
|
-
|
|
|
-
|
|
|
-def delete_account(account_id: str) -> dict:
|
|
|
- with get_connection() as conn:
|
|
|
- deleted = conn.execute("DELETE FROM accounts WHERE id = ?", (account_id,)).rowcount
|
|
|
- conn.commit()
|
|
|
- if not deleted:
|
|
|
- raise HTTPException(status_code=404, detail="account not found")
|
|
|
- return {"id": account_id, "deleted": True}
|
|
|
-
|
|
|
-
|
|
|
-def record_balance(account_id: 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 id = ?", (account_id,)).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 {"id": account_id, "asset_code": asset_code, "balance_value": balance_value, "captured_at": now}
|
|
|
+ account = repo.get_account(account_id)
|
|
|
+ if account["venue"] == "bitstamp":
|
|
|
+ return fetch_remote_account_info(account_id)
|
|
|
+ raise HTTPException(status_code=400, detail="unsupported venue")
|
|
|
|
|
|
|
|
|
def main() -> None:
|