5.8 KiB
5.8 KiB
H2 Traccar Database Integration
Reference for MCP servers that query a remote Traccar H2 database via SSH + Java subprocess.
Architecture
MCP Server (on Core)
└── on each tool call:
1. SSH cat from app2 → /tmp/ft360_db/database.mv.db (local file cache)
2. java -cp /tmp/h2.jar org.h2.tools.Shell → query
3. Parse pipe-delimited output
4. Return JSON
Database Copy (SSH cat — avoid scp scanner block)
# scp is often blocked by raw-IP security scanner; use ssh cat instead:
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=15 \
-i /root/.ssh/itpp-infra root@152.53.39.202 \
"cat /root/docker/traccar/traccar-data/database.mv.db" \
> /tmp/ft360_db/database.mv.db
In Python:
cmd = ["ssh", "-o", "StrictHostKeyChecking=no", "-i", SSH_KEY,
f"root@{APP2_HOST}", f"cat {REMOTE_DB}"]
with open(LOCAL_DB_PATH, "wb") as f:
subprocess.run(cmd, stdout=f, timeout=30)
H2 JDBC URL
The H2 MVStore file is database.mv.db. Strip the .mv.db extension for the JDBC URL:
jdbc:h2:file:/tmp/ft360_db/database;IFEXISTS=TRUE
Query via H2 Shell
java -cp /tmp/h2.jar org.h2.tools.Shell \
-url "jdbc:h2:file:/tmp/ft360_db/database;IFEXISTS=TRUE" \
-user sa -password "" \
-sql "SELECT id, name, uniqueid FROM tc_devices"
Default credentials: user=sa, password="" (empty).
Output Parsing
H2 Shell produces pipe-delimited tabular output:
ID | NAME | UNIQUEID | LASTUPDATE | STATUS
1 | GMB-iPhone | 612982 | 2026-07-13 19:34:27.841 | offline
(1 row, 15 ms)
Parse with three mandatory safety checks:
def parse_h2_output(output: str) -> list[list[str]]:
rows = []
for line in output.strip().split("\n"):
stripped = line.strip()
if not stripped or stripped.startswith("(") or stripped.startswith("---"):
continue
cells = [c.strip() for c in stripped.split("|")]
if all(c == "" for c in cells):
continue
# (1) MANDATORY: skip column header rows like "ID | NAME | ..."
# H2 Shell repeats headers in every result set. Passing these
# to int("ID") or float("NAME") crashes the parser at runtime.
if cells[0].upper() in {"ID", "DEVICEID", "LATITUDE", "NAME", "COUNT(*)"}:
continue
rows.append(cells)
return rows
- Header row crash — H2 Shell repeats
ID | NAME | DEVICEID | ...headers in every result set. Passing them directly toint("ID")orfloat("LATITUDE")crashes the parser withValueError. Skip rows whose first cell matches case-folded known header names. Theall(c == "")check above is not sufficient: header rows have real content that passes it. - Stale snapshot — Always SCP the remote DB before each tool call. An
if not existsguard (copy once, cache forever) silently returns hours/days-old data while the live DB has fresh positions. The MCP cache TTL controls tool response caching, not DB snapshot age. Use_scp_db()unconditionally before_query_db(). - Server-local timestamps, not UTC — H2 timestamps from a Traccar deployment on app2 are stored in the container's local timezone (Eastern, matching the server's
date), not UTC. Age calculations usingdatetime.utcnow()will be off by the timezone offset (4 hours in this case). Usedatetime.now()for naive timestamps stored in server-local time.
Schema
-- tc_devices
SELECT id, name, uniqueid, lastupdate, status FROM tc_devices;
-- tc_positions
SELECT id, deviceid, latitude, longitude, speed, devicetime FROM tc_positions;
speedis in knots. Convert to mph:speed_mph = speed_kn * 1.15078.devicetimeformat:2026-07-13 19:34:27.841(fractional seconds optional).
Key Queries
Devices with latest position
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 (
SELECT deviceid, latitude, longitude, speed, devicetime,
ROW_NUMBER() OVER (PARTITION BY deviceid ORDER BY devicetime DESC) rn
FROM tc_positions
) p ON d.id = p.deviceid AND p.rn = 1
ORDER BY d.id
Position history (last N hours)
SELECT latitude, longitude, speed, devicetime
FROM tc_positions
WHERE deviceid = 1
AND devicetime >= DATEADD('HOUR', -6, NOW())
ORDER BY devicetime ASC
Distance Calculation (Haversine)
import math
def haversine_miles(lat1, lon1, lat2, lon2):
R = 3958.8 # Earth radius in miles
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (math.sin(dlat / 2) ** 2 +
math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) *
math.sin(dlon / 2) ** 2)
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
return R * c
Total trip distance = sum of haversine distances between consecutive positions.
Caching
30-second TTL in-memory dict cache per tool/key avoids redundant SSH + H2 calls:
_cache: dict[str, tuple[float, str]] = {}
CACHE_TTL = 30
def _cache_get(key: str) -> str | None:
entry = _cache.get(key)
if entry is None: return None
ts, value = entry
if time.time() - ts > CACHE_TTL:
del _cache[key]
return None
return value
Pitfalls
- H2 URL without
;IFEXISTS=TRUE→ the Shell tool creates a new empty database if the file doesn't exist, masking copy failures. Always useIFEXISTS=TRUE. - Timestamp parsing —
devicetimemay or may not include fractional seconds (.841). Usetimestamp_string[:19]when parsing withdatetime.strptime. - Database locked by running Traccar — the MVStore format supports reading while Traccar is running, but concurrent writes during the
ssh catmay produce a slightly stale snapshot. Acceptable for read-only analytics at 30s cache TTL.