firegex/backend/mcp_api/store.py
av0idd4rk 863422f202 Add authenticated MCP server and agent guide
Expose 28 tools with shared REST operations, role-scoped bearer authentication, revisions, durable retries, snapshots, jobs and timed rollback. Add Docker verification harnesses and setup/workflow documentation in docs/mcp.md and inst.md. Validated with 330 unit tests, two consecutive 25-case MCP/import runs, and restart recovery; the full upstream regression run remains incomplete.
2026-10-23 09:46:44 +03:00

124 lines
5.2 KiB
Python

"""MCP metadata, separate from the schema-hashed product databases and their backups."""
import hashlib
import json
import os
import secrets
import sqlite3
import time
from pathlib import Path
def encode(value):
return json.dumps(value, sort_keys=True, ensure_ascii=True, separators=(",", ":"))
def digest(value):
return hashlib.sha256(encode(value).encode()).hexdigest()
class Store:
def __init__(self, path=None):
self.path = Path(path or os.getenv("FIREGEX_MCP_DB", "db/mcp/control.sqlite3"))
self.conn = None
def open(self):
if self.conn is not None:
return
self.path.parent.mkdir(parents=True, exist_ok=True)
self.conn = sqlite3.connect(self.path, timeout=10)
self.conn.row_factory = sqlite3.Row
self.conn.executescript("""
CREATE TABLE IF NOT EXISTS tokens (
id TEXT PRIMARY KEY, name TEXT NOT NULL, hash TEXT UNIQUE NOT NULL,
role TEXT NOT NULL, expires REAL NOT NULL, revoked INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS records (
kind TEXT NOT NULL, id TEXT NOT NULL, created REAL NOT NULL,
value TEXT NOT NULL, PRIMARY KEY(kind, id)
);
CREATE TABLE IF NOT EXISTS requests (
actor TEXT NOT NULL, id TEXT NOT NULL, fingerprint TEXT NOT NULL,
change_id TEXT NOT NULL, PRIMARY KEY(actor, id)
);
""")
self.conn.commit()
os.chmod(self.path, 0o600)
def close(self):
if self.conn is not None:
self.conn.close()
self.conn = None
def token_create(self, name, role="operator", hours=168):
if role not in {"observer", "operator", "admin"} or not 0 < hours <= 8760:
raise ValueError("Use observer/operator/admin and a TTL between 0 and 8760 hours")
raw = "fgx_" + secrets.token_urlsafe(32)
token_id = secrets.token_hex(8)
with self.conn:
self.conn.execute("INSERT INTO tokens VALUES (?, ?, ?, ?, ?, 0)",
(token_id, name, hashlib.sha256(raw.encode()).hexdigest(),
role, time.time() + hours * 3600))
return {"id": token_id, "name": name, "role": role, "token": raw}
def token_verify(self, raw):
row = self.conn.execute(
"SELECT id,name,role,expires FROM tokens WHERE hash=? AND revoked=0 AND expires>?",
(hashlib.sha256(raw.encode()).hexdigest(), time.time()),
).fetchone()
return dict(row) if row else None
def token_list(self):
return [dict(r) for r in self.conn.execute("SELECT id,name,role,expires,revoked FROM tokens")]
def token_revoke(self, token_id):
with self.conn:
cur = self.conn.execute("UPDATE tokens SET revoked=1 WHERE id=?", (token_id,))
return bool(cur.rowcount)
def put(self, kind, record_id, value):
with self.conn:
self.conn.execute("INSERT INTO records VALUES (?, ?, ?, ?) ON CONFLICT(kind,id) "
"DO UPDATE SET value=excluded.value", (kind, record_id, time.time(), encode(value)))
def get(self, kind, record_id):
row = self.conn.execute("SELECT value FROM records WHERE kind=? AND id=?", (kind, record_id)).fetchone()
return json.loads(row[0]) if row else None
def list(self, kind, limit=100, offset=0):
return [json.loads(r[0]) for r in self.conn.execute(
"SELECT value FROM records WHERE kind=? ORDER BY created DESC,id LIMIT ? OFFSET ?",
(kind, limit, offset))]
def delete(self, kind, record_id):
with self.conn:
self.conn.execute("DELETE FROM records WHERE kind=? AND id=?", (kind, record_id))
def pending(self, *states):
return [json.loads(r[0]) for r in self.conn.execute(
"SELECT value FROM records WHERE kind='change' AND json_extract(value,'$.status') IN ("
+ ",".join("?" for _ in states) + ") ORDER BY created", states)]
def expire_artifacts(self):
with self.conn:
self.conn.execute("DELETE FROM records WHERE kind='artifact' AND json_extract(value,'$.expires_at') < ?",
(time.time(),))
def request_get(self, actor, request_id):
row = self.conn.execute("SELECT fingerprint,change_id FROM requests WHERE actor=? AND id=?",
(actor, request_id)).fetchone()
return dict(row) if row else None
def request_put(self, actor, request_id, fingerprint, change_id):
with self.conn:
self.conn.execute("INSERT INTO requests VALUES (?, ?, ?, ?)",
(actor, request_id, fingerprint, change_id))
def queue_change(self, change, request):
"""Persist the work and its retry key in the same SQLite transaction."""
with self.conn:
self.conn.execute("INSERT INTO records VALUES ('change', ?, ?, ?)",
(change["id"], time.time(), encode(change)))
if request:
self.conn.execute("INSERT INTO requests VALUES (?, ?, ?, ?)",
(request["actor"], request["id"], request["fingerprint"], change["id"]))