158 lines
5.8 KiB
Markdown
158 lines
5.8 KiB
Markdown
# Remote H2 Database Query via SSH
|
||
|
||
When an MCP server needs to read from an H2 database on a remote host (e.g., Traccar on app2), use this pattern:
|
||
|
||
## Architecture
|
||
|
||
```
|
||
MCP tool call
|
||
→ SSH into remote host (key-based, no password prompt)
|
||
→ scp the .mv.db file to a local temp file
|
||
→ Java H2 Shell query (org.h2.tools.Shell)
|
||
→ Parse pipe-delimited output
|
||
→ Delete temp file
|
||
→ Return structured JSON
|
||
```
|
||
|
||
## Why this pattern
|
||
|
||
- **H2 databases lock.** You cannot query a live H2 database from two processes simultaneously. Copying with `scp` creates a read-only snapshot — the copy succeeds even while the DB is in use, and you query the copy.
|
||
- **Java is needed.** H2's file format is proprietary. Python libraries like `jaydebeapi` exist but are brittle; the H2 Shell jar is self-contained and reliable.
|
||
- **30-second cache.** The SCP + Java query takes 3-5 seconds. Caching avoids this overhead on repeated calls.
|
||
|
||
## Prerequisites
|
||
|
||
On the local host:
|
||
```bash
|
||
apt-get install -y default-jre-headless
|
||
# Copy H2 jar from the remote host or the container
|
||
scp root@remote:/tmp/h2.jar /tmp/h2.jar
|
||
# Test: java -cp /tmp/h2.jar org.h2.tools.Shell --help
|
||
```
|
||
|
||
On the remote host (the H2 jar must exist):
|
||
```bash
|
||
# If inside a Docker container:
|
||
docker cp container_name:/opt/app/lib/h2-*.jar /tmp/h2.jar
|
||
```
|
||
|
||
## Implementation Template
|
||
|
||
```python
|
||
import subprocess, tempfile, os, time
|
||
|
||
HOST = "152.53.xxx.xxx"
|
||
DB_PATH = "/root/docker/app/data/database"
|
||
CACHE_TTL = 30
|
||
|
||
_cache = {"data": None, "ts": 0}
|
||
|
||
def query_devices():
|
||
if _cache["data"] and (time.time() - _cache["ts"]) < CACHE_TTL:
|
||
return _cache["data"]
|
||
|
||
tmp_db = tempfile.mktemp(suffix=".mv.db")
|
||
# 1. Copy DB snapshot from remote
|
||
subprocess.run(
|
||
["scp", "-i", "/root/.ssh/itpp-infra", "-o", "StrictHostKeyChecking=no",
|
||
f"root@{HOST}:{DB_PATH}.mv.db", tmp_db],
|
||
capture_output=True, timeout=15, check=True
|
||
)
|
||
|
||
# 2. Query with Java H2 Shell
|
||
base = tmp_db.replace(".mv.db", "")
|
||
result = subprocess.run(
|
||
["java", "-cp", "/tmp/h2.jar", "org.h2.tools.Shell",
|
||
"-url", f"jdbc:h2:{base}", "-user", "sa", "-password", "",
|
||
"-sql", "SELECT id, name, latitude, longitude FROM tc_devices;"],
|
||
capture_output=True, text=True, timeout=20
|
||
)
|
||
os.unlink(tmp_db)
|
||
|
||
# 3. Parse pipe-delimited output (H2 Shell format)
|
||
# Output: "ID | NAME | LATITUDE | LONGITUDE\n1 | foo | 10.4 | -75.5"
|
||
rows = []
|
||
for line in result.stdout.strip().split('\n'):
|
||
parts = [p.strip() for p in line.split('|')]
|
||
if len(parts) >= 4 and parts[0].strip().isdigit():
|
||
rows.append({
|
||
"id": int(parts[0]),
|
||
"name": parts[1] if parts[1] != "null" else None,
|
||
})
|
||
|
||
data = {"rows": rows, "count": len(rows)}
|
||
_cache["data"] = data
|
||
_cache["ts"] = time.time()
|
||
return data
|
||
```
|
||
|
||
## Traccar Schema Reference (GPS Tracking)
|
||
|
||
These are the live table schemas used by Traccar 6.x. All queries go through the H2 database at `/root/docker/traccar/traccar-data/database.mv.db` on app2 (152.53.39.202).
|
||
|
||
### tc_devices
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| id | INT | Primary key |
|
||
| name | VARCHAR | User-assigned device name |
|
||
| uniqueid | VARCHAR | Device identifier (IMEI, serial) |
|
||
| lastupdate | TIMESTAMP | Last position received |
|
||
| status | VARCHAR | "online", "offline", or "unknown" |
|
||
|
||
### tc_positions
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| id | BIGINT | Auto-increment |
|
||
| deviceid | INT | FK → tc_devices.id |
|
||
| latitude | DOUBLE | Decimal degrees |
|
||
| longitude | DOUBLE | Decimal degrees |
|
||
| speed | DOUBLE | Knots (× 1.15078 for mph) |
|
||
| devicetime | TIMESTAMP | Device-reported time |
|
||
|
||
### Common Queries
|
||
|
||
```sql
|
||
-- All devices with latest position (one query)
|
||
SELECT d.id, d.name, d.uniqueid, d.lastupdate, d.status,
|
||
p.latitude, p.longitude, p.speed, p.devicetime
|
||
FROM tc_devices d
|
||
LEFT JOIN tc_positions p ON p.id = (
|
||
SELECT MAX(id) FROM tc_positions WHERE deviceid = d.id
|
||
)
|
||
ORDER BY d.lastupdate DESC;
|
||
|
||
-- Position history for one device
|
||
SELECT latitude, longitude, speed, devicetime
|
||
FROM tc_positions WHERE deviceid = 1
|
||
ORDER BY devicetime ASC;
|
||
|
||
-- Stats: distance (haversine), max speed, active time
|
||
SELECT COUNT(*) as positions,
|
||
MAX(speed) * 1.15078 as max_speed_mph
|
||
FROM tc_positions
|
||
WHERE deviceid = 1
|
||
AND devicetime >= NOW() - INTERVAL '24' HOUR;
|
||
```
|
||
|
||
## FastAPI Proxy Integration
|
||
|
||
When the MCP server is slow (3-5s per SCP+Java query), serve data through a FastAPI proxy with 30-second caching. The ops portal at `/opt/ops-portal/server.py` uses this pattern:
|
||
|
||
```python
|
||
# /api/ft360/status endpoint proxies Traccar data
|
||
@app.get("/api/ft360/status")
|
||
async def traccar_status():
|
||
return _get_traccar_data() # cached 30s, SCP+Java underneath
|
||
```
|
||
|
||
The JavaScript dashboard fetches from `/api/ft360/status` every 30 seconds — same TTL as the cache. No authentication needed; the proxy handles the SSH and Java complexity.
|
||
|
||
- **Java must be on PATH.** Install `default-jre-headless` before using. The `java` binary in Docker containers (e.g., `/opt/traccar/jre/bin/java`) isn't on PATH by default.
|
||
- **H2 jar must be on the same host and compatible.** Copy it from the container or remote host — don't download a different version from Maven Central.
|
||
- **H2 Shell output format.** The first line is column headers (`ID | NAME | ...`), subsequent lines are data. Filter with `.isdigit()` on the first column.
|
||
- **SCP key must work.** The SSH key path must be absolute, and `StrictHostKeyChecking=no` avoids prompt-freezing on first connection.
|
||
- **Temp file cleanup.** Always `os.unlink(tmp_db)` in a `finally` block or immediately after the query. A 176KB file won't cause issues if left, but it's sloppy.
|
||
- **Cache invalidation.** If you stop+start the remote container (restarting the DB), the cache may return stale data until the TTL expires. For write operations, skip the cache.
|