Coverage for mcpgateway/version.py: 80%

1# -*- coding: utf-8 -*-

2"""version.py - diagnostics endpoint (HTML + JSON)

3

4 Copyright 2025

5 SPDX-License-Identifier: Apache-2.0

6 Authors: Mihai Criveti

7

8 A FastAPI router that mounts at /version and returns either:

9- JSON - machine-readable diagnostics payload

10- HTML - a lightweight dashboard when the client requests text/html or ?format=html

11

12 Features:

13- Cross-platform system metrics (Windows/macOS/Linux), with fallbacks where APIs are unavailable

14- Optional dependencies: psutil (for richer metrics) and redis.asyncio (for Redis health); omitted gracefully if absent

15- Authentication enforcement via `require_auth`; unauthenticated browsers see login form, API clients get JSON 401

16- Redacted environment variables, sanitized DB/Redis URLs, Git commit detection

17"""

18

19# Future

20 from __future__ import annotations

21

22# Standard

23 from datetime import datetime, timezone

24 import json

25 import os

26 import platform

27 import socket

28 import subprocess

29 import time

30 from typing import Any, Dict, Optional

31 from urllib.parse import urlsplit, urlunsplit

32

33# Third-Party

34 from fastapi import APIRouter, Depends, Request

35 from fastapi.responses import HTMLResponse, JSONResponse, Response

36 from fastapi.templating import Jinja2Templates

37 from sqlalchemy import text

38

39# First-Party

40 from mcpgateway.config import settings

41 from mcpgateway.db import engine

42 from mcpgateway.utils.verify_credentials import require_auth

43

44# Optional runtime dependencies

45 try:

46 # Third-Party

47 import psutil # optional for enhanced metrics

48 except ImportError:

49 psutil = None # type: ignore

50

51 try:

52 # Third-Party

53 import redis.asyncio as aioredis # optional Redis health check

54

55 REDIS_AVAILABLE = True

56 except ImportError:

57 aioredis = None # type: ignore

58 REDIS_AVAILABLE = False

59

60# Globals

61

62 START_TIME = time.time()

63 HOSTNAME = socket.gethostname()

64 LOGIN_PATH = "/login"

65 router = APIRouter(tags=["meta"])

66

67

68 def _is_secret(key: str) -> bool:

69 """

70 Identify if an environment variable key likely represents a secret.

71

72 Parameters:

73 key (str): The environment variable name.

74

75 Returns:

76 bool: True if the key contains secret-looking keywords, False otherwise.

77 """

78 return any(tok in key.upper() for tok in ("SECRET", "TOKEN", "PASS", "KEY"))

79

80

81 def _public_env() -> Dict[str, str]:

82 """

83 Collect environment variables excluding those that look secret.

84

85 Returns:

86 Dict[str, str]: A map of environment variable names to values.

87 """

88 return {k: v for k, v in os.environ.items() if not _is_secret(k)}

89

90

91 def _git_revision() -> Optional[str]:

92 """

93 Retrieve the current Git revision (short) if available.

94

95 Returns:

96 Optional[str]: The Git commit hash prefix or None if unavailable.

97 """

98 rev = os.getenv("GIT_COMMIT")

99 if rev:

100 return rev[:9]

101 try:

102 out = subprocess.check_output(

103 ["git", "rev-parse", "--short", "HEAD"],

104 stderr=subprocess.DEVNULL,

105 )

106 return out.decode().strip()

107 except Exception:

108 return None

109

110

111 def _sanitize_url(url: Optional[str]) -> Optional[str]:

112 """

113 Redact credentials from a URL for safe display.

114

115 Parameters:

116 url (Optional[str]): The URL to sanitize.

117

118 Returns:

119 Optional[str]: The sanitized URL or None.

120 """

121 if not url: 121 ↛ 122line 121 didn't jump to line 122 because the condition on line 121 was never true

122 return None

123 parts = urlsplit(url)

124 if parts.password:

125 netloc = f"{parts.username}@{parts.hostname}{':' + str(parts.port) if parts.port else ''}"

126 parts = parts._replace(netloc=netloc)

127 return urlunsplit(parts)

128

129

130 def _database_version() -> tuple[str, bool]:

131 """

132 Query the database server version.

133

134 Returns:

135 tuple[str, bool]: (version string or error message, reachable flag).

136 """

137 dialect = engine.dialect.name

138 stmts = {

139 "sqlite": "SELECT sqlite_version();",

140 "postgresql": "SELECT current_setting('server_version');",

141 "mysql": "SELECT version();",

142 }

143 stmt = stmts.get(dialect, "SELECT version();")

144 try:

145 with engine.connect() as conn:

146 ver = conn.execute(text(stmt)).scalar()

147 return str(ver), True

148 except Exception as exc:

149 return str(exc), False

150

151

152 def _system_metrics() -> Dict[str, Any]:

153 """

154 Gather system-wide and per-process metrics using psutil, falling back gracefully

155 if psutil is not installed or certain APIs are unavailable.

156

157 Returns:

158 Dict[str, Any]: A dictionary containing:

159 - boot_time (str): ISO-formatted system boot time.

160 - cpu_percent (float): Total CPU utilization percentage.

161 - cpu_count (int): Number of logical CPU cores.

162 - cpu_freq_mhz (int | None): Current CPU frequency in MHz, or None if unavailable.

163 - load_avg (tuple[float | None, float | None, float | None]):

164 System load average over 1, 5, and 15 minutes, or (None, None, None)

165 on platforms without getloadavg.

166 - mem_total_mb (int): Total physical memory in megabytes.

167 - mem_used_mb (int): Used physical memory in megabytes.

168 - swap_total_mb (int): Total swap memory in megabytes.

169 - swap_used_mb (int): Used swap memory in megabytes.

170 - disk_total_gb (float): Total size of the root disk partition in gigabytes.

171 - disk_used_gb (float): Used space of the root disk partition in gigabytes.

172 - process (Dict[str, Any]): A nested dict with per-process metrics:

173 * pid (int): Current process ID.

174 * threads (int): Number of active threads.

175 * rss_mb (float): Resident Set Size memory usage in megabytes.

176 * vms_mb (float): Virtual Memory Size usage in megabytes.

177 * open_fds (int | None): Number of open file descriptors, or None if unsupported.

178 * proc_cpu_percent (float): CPU utilization percentage for this process.

179 {}: Empty dict if psutil is not installed.

180 """

181 if not psutil: 181 ↛ 182line 181 didn't jump to line 182 because the condition on line 181 was never true

182 return {}

183

184 # System memory and swap

185 vm = psutil.virtual_memory()

186 swap = psutil.swap_memory()

187

188 # Load average (Unix); on Windows returns (None, None, None)

189 try:

190 load = tuple(round(x, 2) for x in os.getloadavg())

191 except (AttributeError, OSError):

192 load = (None, None, None)

193

194 # CPU metrics

195 freq = psutil.cpu_freq()

196 cpu_pct = psutil.cpu_percent(interval=0.3)

197 cpu_count = psutil.cpu_count(logical=True)

198

199 # Process metrics

200 proc = psutil.Process()

201 try:

202 open_fds = proc.num_fds()

203 except Exception:

204 open_fds = None

205 proc_cpu_pct = proc.cpu_percent(interval=0.1)

206 rss_mb = round(proc.memory_info().rss / 1_048_576, 2)

207 vms_mb = round(proc.memory_info().vms / 1_048_576, 2)

208 threads = proc.num_threads()

209 pid = proc.pid

210

211 # Disk usage for root partition (ensure str on Windows)

212 root = os.getenv("SystemDrive", "C:\\") if os.name == "nt" else "/"

213 disk = psutil.disk_usage(str(root))

214 disk_total_gb = round(disk.total / 1_073_741_824, 2)

215 disk_used_gb = round(disk.used / 1_073_741_824, 2)

216

217 return {

218 "boot_time": datetime.fromtimestamp(psutil.boot_time()).isoformat(),

219 "cpu_percent": cpu_pct,

220 "cpu_count": cpu_count,

221 "cpu_freq_mhz": round(freq.current) if freq else None,

222 "load_avg": load,

223 "mem_total_mb": round(vm.total / 1_048_576),

224 "mem_used_mb": round(vm.used / 1_048_576),

225 "swap_total_mb": round(swap.total / 1_048_576),

226 "swap_used_mb": round(swap.used / 1_048_576),

227 "disk_total_gb": disk_total_gb,

228 "disk_used_gb": disk_used_gb,

229 "process": {

230 "pid": pid,

231 "threads": threads,

232 "rss_mb": rss_mb,

233 "vms_mb": vms_mb,

234 "open_fds": open_fds,

235 "proc_cpu_percent": proc_cpu_pct,

236 },

237 }

238

239

240 def _build_payload(

241 redis_version: Optional[str],

242 redis_ok: bool,

243) -> Dict[str, Any]:

244 """

245 Build the complete diagnostics payload.

246

247 Parameters:

248 redis_version (Optional[str]): Version or error for Redis.

249 redis_ok (bool): Whether Redis is reachable.

250

251 Returns:

252 Dict[str, Any]: Structured diagnostics data.

253 """

254 db_ver, db_ok = _database_version()

255 return {

256 "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),

257 "host": HOSTNAME,

258 "uptime_seconds": int(time.time() - START_TIME),

259 "app": {

260 "name": settings.app_name,

261 "mcp_protocol_version": settings.protocol_version,

262 "git_revision": _git_revision(),

263 },

264 "platform": {

265 "python": platform.python_version(),

266 "fastapi": __import__("fastapi").__version__,

267 "sqlalchemy": __import__("sqlalchemy").__version__,

268 "os": f"{platform.system()} {platform.release()} ({platform.machine()})",

269 },

270 "database": {

271 "dialect": engine.dialect.name,

272 "url": _sanitize_url(settings.database_url),

273 "reachable": db_ok,

274 "server_version": db_ver,

275 },

276 "redis": {

277 "available": REDIS_AVAILABLE,

278 "url": _sanitize_url(settings.redis_url),

279 "reachable": redis_ok,

280 "server_version": redis_version,

281 },

282 "settings": {

283 "cache_type": settings.cache_type,

284 "mcpgateway_ui_enabled": getattr(settings, "mcpgateway_ui_enabled", None),

285 "mcpgateway_admin_api_enabled": getattr(settings, "mcpgateway_admin_api_enabled", None),

286 },

287 "env": _public_env(),

288 "system": _system_metrics(),

289 }

290

291

292 def _html_table(obj: Dict[str, Any]) -> str:

293 """

294 Render a dict as an HTML table.

295

296 Parameters:

297 obj (Dict[str, Any]): The data to render.

298

299 Returns:

300 str: HTML table markup.

301 """

302 rows = "".join(f"<tr><th>{k}</th><td>{json.dumps(v, default=str) if not isinstance(v, str) else v}</td></tr>" for k, v in obj.items())

303 return f"<table>{rows}</table>"

304

305

306 def _render_html(payload: Dict[str, Any]) -> str:

307 """

308 Render the full diagnostics payload as HTML.

309

310 Parameters:

311 payload (Dict[str, Any]): The diagnostics data.

312

313 Returns:

314 str: Complete HTML page.

315 """

316 style = (

317 "<style>"

318 "body{font-family:system-ui,sans-serif;margin:2rem;}"

319 "table{border-collapse:collapse;width:100%;margin-bottom:1rem;}"

320 "th,td{border:1px solid #ccc;padding:.5rem;text-align:left;}"

321 "th{background:#f7f7f7;width:25%;}"

322 "</style>"

323 )

324 header = f"<h1>MCP Gateway diagnostics</h1><p>Generated {payload['timestamp']} - Host {payload['host']} - Uptime {payload['uptime_seconds']}s</p>"

325 sections = ""

326 for title, key in (

327 ("App", "app"),

328 ("Platform", "platform"),

329 ("Database", "database"),

330 ("Redis", "redis"),

331 ("Settings", "settings"),

332 ("System", "system"),

333 ):

334 sections += f"<h2>{title}</h2>{_html_table(payload[key])}"

335 env_section = f"<h2>Environment</h2>{_html_table(payload['env'])}"

336 return f"<!doctype html><html><head><meta charset='utf-8'>{style}</head><body>{header}{sections}{env_section}</body></html>"

337

338

339 def _login_html(next_url: str) -> str:

340 """

341 Render the login form HTML for unauthenticated browsers.

342

343 Parameters:

344 next_url (str): The URL to return to after login.

345

346 Returns:

347 str: HTML of the login page.

348 """

349 return f"""<!doctype html>

350<html><head><meta charset='utf-8'><title>Login - MCP Gateway</title>

351<style>

352 body{{font-family:system-ui,sans-serif;margin:2rem;}}

353 form{{max-width:320px;margin:auto;}}

354 label{{display:block;margin:.5rem 0;}}

355 input{{width:100%;padding:.5rem;}}

356 button{{margin-top:1rem;padding:.5rem 1rem;}}

357</style></head>

358<body>

359 <h2>Please log in</h2>

360 <form action="{LOGIN_PATH}" method="post">

361 <input type="hidden" name="next" value="{next_url}">

362 <label>Username<input type="text" name="username" autocomplete="username"></label>

363 <label>Password<input type="password" name="password" autocomplete="current-password"></label>

364 <button type="submit">Login</button>

365 </form>

366</body></html>"""

367

368

369# Endpoint

370@router.get("/version", summary="Diagnostics (auth required)")

371 async def version_endpoint(

372 request: Request,

373 fmt: Optional[str] = None,

374 partial: Optional[bool] = False,

375 _user=Depends(require_auth),

376) -> Response:

377 """

378 Serve diagnostics as JSON, full HTML, or partial HTML (if requested).

379

380 Parameters:

381 request (Request): The incoming HTTP request.

382 fmt (Optional[str]): Query param 'html' for full HTML output.

383 partial (Optional[bool]): Query param to request partial HTML fragment.

384

385 Returns:

386 Response: JSONResponse or HTMLResponse with diagnostics data.

387 """

388 # Redis health check

389 redis_ok = False

390 redis_version: Optional[str] = None

391 if REDIS_AVAILABLE and settings.cache_type.lower() == "redis" and settings.redis_url: 391 ↛ 392line 391 didn't jump to line 392 because the condition on line 391 was never true

392 try:

393 client = aioredis.Redis.from_url(settings.redis_url)

394 await client.ping()

395 info = await client.info()

396 redis_version = info.get("redis_version")

397 redis_ok = True

398 except Exception as exc:

399 redis_version = str(exc)

400

401 payload = _build_payload(redis_version, redis_ok)

402 if partial: 402 ↛ 404line 402 didn't jump to line 404 because the condition on line 402 was never true

403 # Return partial HTML fragment for HTMX embedding

404 templates = Jinja2Templates(directory=str(settings.templates_dir))

405 return templates.TemplateResponse(request, "version_info_partial.html", {"request": request, "payload": payload})

406 wants_html = fmt == "html" or "text/html" in request.headers.get("accept", "")

407 if wants_html:

408 return HTMLResponse(_render_html(payload))

409 return JSONResponse(payload)