Sfoglia il codice sorgente

Stabilize exec-mcp schema and dashboard

Lukas Goldschmidt 1 mese fa
parent
commit
49b43ec541
5 ha cambiato i file con 145 aggiunte e 120 eliminazioni
  1. 54 0
      DB_SCHEME.md
  2. 1 0
      README.md
  3. 1 0
      requirements.txt
  4. 78 110
      src/exec_mcp/server.py
  5. 11 10
      src/exec_mcp/storage.py

+ 54 - 0
DB_SCHEME.md

@@ -0,0 +1,54 @@
+# exec-mcp database scheme (agreed reference)
+
+## Clean generic scheme
+
+### accounts
+
+- `id` → internal primary key, hidden
+- `display_name` → arbitrary label shown in UI
+- `venue` → exchange name, e.g. `bitstamp`
+- `venue_account_ref` → exchange-side account id, e.g. Bitstamp user id
+- `description` → optional note
+- `enabled` → boolean
+- `metadata_json` → optional extra metadata
+- `created_at`
+- `updated_at`
+
+### account_secrets
+
+- `account_id` → FK to `accounts.id`
+- `api_key`
+- `api_secret`
+- `created_at`
+- `updated_at`
+
+### balance_snapshots
+
+- `id` → internal primary key
+- `account_id` → FK to `accounts.id`
+- `asset_code` → e.g. `BTC`, `EUR`
+- `balance_value`
+- `captured_at`
+
+### order_records
+
+- `id` → internal primary key
+- `account_id` → FK to `accounts.id`
+- `instrument` → e.g. `BTC/EUR`
+- `side`
+- `order_kind`
+- `quantity`
+- `price`
+- `status`
+- `raw_json`
+- `created_at`
+- `updated_at`
+
+## Key rules
+
+- UI never shows `accounts.id`.
+- In communication, distinguish clearly:
+  - **`id`** = internal id used for operations.
+  - **`venue_account_ref`** = external exchange account reference.
+- `venue_account_ref` is **not unique** and must **not** be used for operations.
+- Dashboard operations (update/delete and similar mutations) must use internal `id` only.

+ 1 - 0
README.md

@@ -47,6 +47,7 @@ This folder is the starting scaffold. The architecture and intent are captured i
 - `GET /` shows a minimal landing page with links
 - `GET /dashboard` shows the HTML dashboard
 - the dashboard creates, updates, and deletes accounts with forms
+- the add-account section starts collapsed, since it is only used occasionally
 - the visible account reference is the exchange-side identifier, for example the Bitstamp numeric user id
 - the internal database primary key is separate and hidden from the web UI
 - the `name` field is the arbitrary label you choose for that account

+ 1 - 0
requirements.txt

@@ -1,4 +1,5 @@
 fastapi>=0.110.0
 uvicorn[standard]>=0.23.0
 fastmcp>=2.11.3
+BitstampClient>=2.2.7
 pytest>=8.0.0

+ 78 - 110
src/exec_mcp/server.py

@@ -2,6 +2,7 @@ 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
@@ -27,9 +28,10 @@ SUPPORTED_VENUES = {"bitstamp"}
 
 
 class AccountView(BaseModel):
-    display_name: str
-    venue: str
-    venue_account_ref: str
+    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="{}")
@@ -70,16 +72,16 @@ def http_dashboard() -> str:
     table_rows = "".join(
         f"""
         <tr>
-          <td>{row['display_name']}</td>
-          <td>{row['venue']}</td>
-          <td>{row['venue_account_ref']}</td>
+          <td>{row['display_name'] or ''}</td>
+          <td>{row['venue'] or ''}</td>
+          <td>{row['venue_account_ref'] or ''}</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">
+            <form method="get" action="/dashboard/accounts/{row['id']}/edit">
               <button type="submit">Edit</button>
             </form>
-            <form method="post" action="/dashboard/accounts/{row['venue']}/{row['venue_account_ref']}/delete">
+            <form method="post" action="/dashboard/accounts/{row['id']}/delete">
               <button type="submit" class="danger">Delete</button>
             </form>
           </td>
@@ -96,6 +98,8 @@ def http_dashboard() -> str:
           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; }}
+          summary {{ list-style: none; }}
+          summary::-webkit-details-marker {{ display: none; }}
           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; }}
@@ -104,7 +108,6 @@ def http_dashboard() -> str:
           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>
@@ -112,19 +115,19 @@ def http_dashboard() -> str:
           <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 />
+        <details class="panel">
+          <summary style="cursor:pointer; font-size: 1.1rem; font-weight: 600;">Create account</summary>
+          <form method="post" action="/dashboard/accounts/create" style="margin-top: 12px;">
+            <input name="display_name" placeholder="name" />
             <select name="venue">{options}</select>
-            <input name="venue_account_ref" placeholder="exchange account ref" required />
+            <input name="venue_account_ref" placeholder="exchange account ref" />
             <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>
+        </details>
 
         <div class="panel">
           <h2>Accounts</h2>
@@ -146,9 +149,9 @@ def http_dashboard() -> str:
 
 @app.post("/dashboard/accounts/create")
 def http_dashboard_create_account(
-    display_name: str = Form(...),
+    display_name: str = Form(""),
     venue: str = Form(...),
-    venue_account_ref: str = Form(...),
+    venue_account_ref: str = Form(""),
     api_key: str = Form(...),
     api_secret: str = Form(...),
     description: str | None = Form(None),
@@ -166,12 +169,12 @@ def http_dashboard_create_account(
     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:
+@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 display_name, venue, venue_account_ref, description, enabled FROM accounts WHERE venue = ? AND venue_account_ref = ?",
-            (venue, venue_account_ref),
+            "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")
@@ -179,52 +182,54 @@ def http_dashboard_edit_account(venue: str, venue_account_ref: str) -> str:
     return f"""
     <html>
       <body style="font-family: system-ui, sans-serif; max-width: 700px; margin: 32px auto; padding: 0 16px;">
-        <h1>Edit account</h1>
+        <header style="margin-bottom: 24px; border-bottom: 1px solid #e5e7eb; padding-bottom: 12px;"><h1>Edit account</h1></header>
         <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 />
+        <form method="post" action="/dashboard/accounts/{row['id']}/update" style="display:grid; gap:10px; max-width: 520px;">
+          <input name="display_name" value="{row['display_name'] or ''}" placeholder="name" />
+          <input value="{row['venue'] or ''}" placeholder="venue" readonly />
+          <input value="{row['venue_account_ref'] or ''}" 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>
+        <footer style="margin-top: 32px; padding-top: 12px; border-top: 1px solid #e5e7eb; color: #6b7280;">exec-mcp</footer>
       </body>
     </html>
     """
 
 
-@app.post("/dashboard/accounts/{venue}/{venue_account_ref}/update")
+@app.post("/dashboard/accounts/{account_id}/update")
 def http_dashboard_update_account(
-    venue: str,
-    venue_account_ref: str,
-    display_name: str = Form(...),
+    account_id: 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)
+    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/{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)
+@app.post("/dashboard/accounts/{account_id}/delete")
+def http_dashboard_delete_account(account_id: str) -> RedirectResponse:
+    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 display_name, venue, venue_account_ref, description, enabled, metadata_json, created_at, updated_at FROM accounts"
+    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 display_name ASC"
+    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"],
@@ -239,11 +244,11 @@ def list_accounts(venue: str | None = None) -> list[dict]:
 
 
 @mcp.tool()
-def get_account_info(venue: str, venue_account_ref: str) -> dict:
+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 venue = ? AND venue_account_ref = ?",
-            (venue, venue_account_ref),
+            "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:
@@ -255,8 +260,8 @@ def get_account_info(venue: str, venue_account_ref: str) -> dict:
     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 {
+        "id": account["id"],
         "display_name": account["display_name"],
         "venue": account["venue"],
         "venue_account_ref": account["venue_account_ref"],
@@ -283,97 +288,66 @@ def create_account(
     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()
+        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 {
-        "display_name": display_name,
-        "venue": venue,
-        "venue_account_ref": venue_account_ref,
-        "enabled": enabled,
-    }
+    return {"id": account_pk, "display_name": display_name, "venue": venue}
 
 
 def update_account(
-    venue: str,
-    venue_account_ref: str,
+    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 venue = ? AND venue_account_ref = ?",
-            (venue, venue_account_ref),
-        ).fetchone()
+        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, row["id"]),
-            )
+            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, row["id"]),
-            )
+            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, row["id"]),
-            )
+            conn.execute("UPDATE accounts SET enabled = ?, updated_at = ? WHERE id = ?", (int(enabled), now, account_id))
         conn.commit()
-    return {"venue": venue, "venue_account_ref": venue_account_ref, "updated": True}
+    return {"id": account_id, "updated": True}
 
 
-def delete_account(venue: str, venue_account_ref: str) -> dict:
+def delete_account(account_id: str) -> dict:
     with get_connection() as conn:
-        deleted = conn.execute(
-            "DELETE FROM accounts WHERE venue = ? AND venue_account_ref = ?",
-            (venue, venue_account_ref),
-        ).rowcount
+        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 {"venue": venue, "venue_account_ref": venue_account_ref, "deleted": True}
+    return {"id": account_id, "deleted": True}
 
 
-def record_balance(venue: str, venue_account_ref: str, asset_code: str, balance_value: float) -> dict:
+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 venue = ? AND venue_account_ref = ?",
-            (venue, venue_account_ref),
-        ).fetchone()
+        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(
@@ -381,13 +355,7 @@ def record_balance(venue: str, venue_account_ref: str, asset_code: str, balance_
             (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,
-    }
+    return {"id": account_id, "asset_code": asset_code, "balance_value": balance_value, "captured_at": now}
 
 
 def main() -> None:

+ 11 - 10
src/exec_mcp/storage.py

@@ -16,25 +16,24 @@ def get_connection() -> sqlite3.Connection:
 
 def init_db() -> None:
     with get_connection() as conn:
-        # Stable final schema for the app foundation.
+        # Non-destructive schema bootstrap. Keep this stable from here on.
         conn.executescript(
             """
             CREATE TABLE IF NOT EXISTS accounts (
                 id TEXT PRIMARY KEY,
-                display_name TEXT NOT NULL,
-                venue TEXT NOT NULL,
-                venue_account_ref TEXT NOT NULL,
+                display_name TEXT,
+                venue TEXT,
+                venue_account_ref TEXT,
                 description TEXT,
                 enabled INTEGER NOT NULL DEFAULT 1,
                 metadata_json TEXT NOT NULL DEFAULT '{}',
                 created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
-                updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
-                UNIQUE(venue, venue_account_ref)
+                updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
             );
 
             CREATE TABLE IF NOT EXISTS account_secrets (
                 account_id TEXT PRIMARY KEY,
-                api_key TEXT NOT NULL,
+                api_key TEXT NOT NULL UNIQUE,
                 api_secret TEXT NOT NULL,
                 created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
                 updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -42,11 +41,13 @@ def init_db() -> None:
             );
 
             CREATE TABLE IF NOT EXISTS balance_snapshots (
-                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                id TEXT PRIMARY KEY,
                 account_id TEXT NOT NULL,
                 asset_code TEXT NOT NULL,
-                balance_value REAL NOT NULL,
-                captured_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                balance REAL NOT NULL,
+                balance_value REAL,
+                value_currency TEXT,
+                captured_at TEXT NOT NULL,
                 FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE
             );