Coverage for mcpgateway / version.py: 99%

220 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-06 00:56 +0100

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

2"""Location: ./mcpgateway/version.py 

3Copyright 2025 

4SPDX-License-Identifier: Apache-2.0 

5Authors: Mihai Criveti 

6 

7version.py - diagnostics endpoint (HTML + JSON) 

8A 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 

12Features: 

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 

17 

18The module provides comprehensive system diagnostics including application info, 

19platform details, database and Redis connectivity, system metrics, and environment 

20variables (with secrets redacted). 

21 

22Environment variables containing the following patterns are automatically redacted: 

23- Keywords: SECRET, TOKEN, PASS, KEY 

24- Specific vars: BASIC_AUTH_USER, DATABASE_URL, REDIS_URL 

25 

26Examples: 

27 >>> from mcpgateway.version import _is_secret, _sanitize_url, START_TIME, HOSTNAME 

28 >>> _is_secret("DATABASE_PASSWORD") 

29 True 

30 >>> _is_secret("BASIC_AUTH_USER") 

31 True 

32 >>> _is_secret("HOSTNAME") 

33 False 

34 >>> _sanitize_url("redis://user:xxxxx@localhost:6379/0") 

35 'redis://user@localhost:6379/0' 

36 >>> _sanitize_url("postgresql://admin:xxxxx@db.example.com/mydb") 

37 'postgresql://admin@db.example.com/mydb' 

38 >>> _sanitize_url("https://example.com/path") 

39 'https://example.com/path' 

40 >>> isinstance(START_TIME, float) 

41 True 

42 >>> START_TIME > 0 

43 True 

44 >>> isinstance(HOSTNAME, str) 

45 True 

46 >>> len(HOSTNAME) > 0 

47 True 

48""" 

49 

50# Future 

51from __future__ import annotations 

52 

53# Standard 

54import asyncio 

55from datetime import datetime, timezone 

56import importlib.util 

57import os 

58import platform 

59import socket 

60import time 

61from typing import Any, Dict, Optional 

62from urllib.parse import urlsplit, urlunsplit 

63 

64# Third-Party 

65from fastapi import APIRouter, Depends, Request 

66from fastapi.responses import HTMLResponse, Response 

67from fastapi.templating import Jinja2Templates 

68from jinja2 import Environment, FileSystemLoader 

69import orjson 

70from sqlalchemy import text 

71 

72# First-Party 

73from mcpgateway import __version__ 

74from mcpgateway.config import settings 

75from mcpgateway.db import engine 

76from mcpgateway.utils.orjson_response import ORJSONResponse 

77from mcpgateway.utils.redis_client import get_redis_client, is_redis_available 

78from mcpgateway.utils.verify_credentials import require_admin_auth 

79 

80# Optional runtime dependencies 

81try: 

82 # Third-Party 

83 import psutil # optional for enhanced metrics 

84except ImportError: 

85 psutil = None # type: ignore 

86 

87try: 

88 REDIS_AVAILABLE = importlib.util.find_spec("redis.asyncio") is not None 

89except (ModuleNotFoundError, AttributeError) as e: 

90 # ModuleNotFoundError: redis package not installed 

91 # AttributeError: 'redis' exists but isn't a proper package (e.g., shadowed by a file) 

92 # Standard 

93 import logging 

94 

95 logging.getLogger(__name__).warning(f"Redis module check failed ({type(e).__name__}: {e}), Redis support disabled") 

96 REDIS_AVAILABLE = False 

97 

98# Globals 

99 

100START_TIME = time.time() 

101HOSTNAME = socket.gethostname() 

102LOGIN_PATH = "/login" 

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

104 

105 

106def _env_flag(name: str, default: bool = False) -> bool: 

107 """Read a boolean environment variable using common truthy spellings. 

108 

109 Args: 

110 name: Environment variable name. 

111 default: Default value used when the variable is unset. 

112 

113 Returns: 

114 Parsed boolean value. 

115 """ 

116 value = os.getenv(name) 

117 if value is None: 

118 return default 

119 return value.strip().lower() in {"1", "true", "yes", "on"} 

120 

121 

122def _rust_build_included() -> bool: 

123 """Return whether the current image includes Rust MCP artifacts. 

124 

125 Returns: 

126 ``True`` when the current image contains the Rust MCP binaries/plugins. 

127 """ 

128 return _env_flag("CONTEXTFORGE_ENABLE_RUST_BUILD", default=False) 

129 

130 

131def _rust_runtime_managed() -> bool: 

132 """Return whether the gateway expects to manage the Rust MCP sidecar locally. 

133 

134 Returns: 

135 ``True`` when the gateway should launch and supervise the Rust sidecar. 

136 """ 

137 return _env_flag("EXPERIMENTAL_RUST_MCP_RUNTIME_MANAGED", default=True) 

138 

139 

140def _current_mcp_transport_mount() -> str: 

141 """Return which public ``/mcp`` transport is currently mounted. 

142 

143 Returns: 

144 Runtime label identifying the currently mounted public MCP transport. 

145 """ 

146 return "rust" if _should_mount_public_rust_transport() else "python" 

147 

148 

149def _should_mount_public_rust_transport() -> bool: 

150 """Return whether public ``/mcp`` should be served directly by Rust. 

151 

152 Returns: 

153 ``True`` only when the Rust runtime is enabled and Rust can safely own 

154 steady-state public MCP session traffic. 

155 """ 

156 return bool(settings.experimental_rust_mcp_runtime_enabled and settings.experimental_rust_mcp_session_auth_reuse_enabled) 

157 

158 

159def _should_use_rust_public_session_stack() -> bool: 

160 """Return whether Rust should own the effective public MCP session stack. 

161 

162 Returns: 

163 ``True`` only when the public MCP transport and session semantics should 

164 stay on the Rust-backed path. 

165 """ 

166 return _should_mount_public_rust_transport() 

167 

168 

169def _current_mcp_runtime_mode() -> str: 

170 """Return the current MCP runtime mode label used for health and UI surfaces. 

171 

172 Returns: 

173 Human-readable runtime mode label for diagnostics and UI reporting. 

174 """ 

175 if settings.experimental_rust_mcp_runtime_enabled: 

176 return "rust-managed" if _rust_runtime_managed() else "rust-external" 

177 if _rust_build_included(): 

178 return "python-rust-built-disabled" 

179 return "python" 

180 

181 

182def _current_mcp_session_core_mode() -> str: 

183 """Return which runtime currently owns MCP session metadata. 

184 

185 Returns: 

186 ``"rust"`` when the Rust session core is enabled, otherwise ``"python"``. 

187 """ 

188 if _should_use_rust_public_session_stack() and settings.experimental_rust_mcp_session_core_enabled: 

189 return "rust" 

190 return "python" 

191 

192 

193def _current_mcp_event_store_mode() -> str: 

194 """Return which runtime currently owns MCP resumable event-store semantics. 

195 

196 Returns: 

197 ``"rust"`` when the Rust event store is enabled, otherwise ``"python"``. 

198 """ 

199 if _should_use_rust_public_session_stack() and settings.experimental_rust_mcp_event_store_enabled: 

200 return "rust" 

201 return "python" 

202 

203 

204def _current_mcp_resume_core_mode() -> str: 

205 """Return which runtime currently owns public MCP replay/resume behavior. 

206 

207 Returns: 

208 ``"rust"`` when Rust owns replay/resume, otherwise ``"python"``. 

209 """ 

210 if ( 

211 _should_use_rust_public_session_stack() 

212 and settings.experimental_rust_mcp_session_core_enabled 

213 and settings.experimental_rust_mcp_event_store_enabled 

214 and settings.experimental_rust_mcp_resume_core_enabled 

215 ): 

216 return "rust" 

217 return "python" 

218 

219 

220def _current_mcp_live_stream_core_mode() -> str: 

221 """Return which runtime currently owns non-resume public GET ``/mcp`` SSE behavior. 

222 

223 Returns: 

224 ``"rust"`` when Rust owns live GET ``/mcp`` streaming, otherwise ``"python"``. 

225 """ 

226 if _should_use_rust_public_session_stack() and settings.experimental_rust_mcp_live_stream_core_enabled: 

227 return "rust" 

228 return "python" 

229 

230 

231def _current_mcp_affinity_core_mode() -> str: 

232 """Return which runtime currently owns MCP multi-worker session-affinity forwarding. 

233 

234 Returns: 

235 ``"rust"`` when Rust owns session-affinity forwarding, otherwise ``"python"``. 

236 """ 

237 if _should_use_rust_public_session_stack() and settings.experimental_rust_mcp_affinity_core_enabled: 

238 return "rust" 

239 return "python" 

240 

241 

242def _current_mcp_session_auth_reuse_mode() -> str: 

243 """Return which runtime currently owns MCP session-bound auth-context reuse. 

244 

245 Returns: 

246 ``"rust"`` when Rust session auth reuse is enabled, otherwise ``"python"``. 

247 """ 

248 if settings.experimental_rust_mcp_runtime_enabled and settings.experimental_rust_mcp_session_auth_reuse_enabled: 

249 return "rust" 

250 return "python" 

251 

252 

253def _mcp_runtime_status_payload() -> Dict[str, Any]: 

254 """Return MCP runtime diagnostics for health, UI, and version surfaces. 

255 

256 Returns: 

257 Diagnostic payload describing the active MCP runtime configuration. 

258 """ 

259 payload: Dict[str, Any] = { 

260 "mode": _current_mcp_runtime_mode(), 

261 "mounted": _current_mcp_transport_mount(), 

262 "rust_build_included": _rust_build_included(), 

263 "rust_runtime_enabled": settings.experimental_rust_mcp_runtime_enabled, 

264 "session_core_mode": _current_mcp_session_core_mode(), 

265 "event_store_mode": _current_mcp_event_store_mode(), 

266 "resume_core_mode": _current_mcp_resume_core_mode(), 

267 "live_stream_core_mode": _current_mcp_live_stream_core_mode(), 

268 "affinity_core_mode": _current_mcp_affinity_core_mode(), 

269 "session_auth_reuse_mode": _current_mcp_session_auth_reuse_mode(), 

270 "rust_session_core_enabled": bool(_should_use_rust_public_session_stack() and settings.experimental_rust_mcp_session_core_enabled), 

271 "rust_event_store_enabled": bool(_should_use_rust_public_session_stack() and settings.experimental_rust_mcp_event_store_enabled), 

272 "rust_resume_core_enabled": bool( 

273 _should_use_rust_public_session_stack() 

274 and settings.experimental_rust_mcp_session_core_enabled 

275 and settings.experimental_rust_mcp_event_store_enabled 

276 and settings.experimental_rust_mcp_resume_core_enabled 

277 ), 

278 "rust_live_stream_core_enabled": bool(_should_use_rust_public_session_stack() and settings.experimental_rust_mcp_live_stream_core_enabled), 

279 "rust_affinity_core_enabled": bool(_should_use_rust_public_session_stack() and settings.experimental_rust_mcp_affinity_core_enabled), 

280 "rust_session_auth_reuse_enabled": bool(settings.experimental_rust_mcp_runtime_enabled and settings.experimental_rust_mcp_session_auth_reuse_enabled), 

281 } 

282 

283 if settings.experimental_rust_mcp_runtime_enabled: 

284 payload["rust_runtime_managed"] = _rust_runtime_managed() 

285 if settings.experimental_rust_mcp_runtime_uds: 

286 payload["sidecar_transport"] = "uds" 

287 payload["sidecar_target"] = settings.experimental_rust_mcp_runtime_uds 

288 else: 

289 payload["sidecar_transport"] = "http" 

290 payload["sidecar_target"] = settings.experimental_rust_mcp_runtime_url 

291 

292 return payload 

293 

294 

295def rust_build_included() -> bool: 

296 """Return whether the current image includes Rust MCP artifacts. 

297 

298 Returns: 

299 ``True`` when the current image contains the Rust MCP binaries/plugins. 

300 """ 

301 return _rust_build_included() 

302 

303 

304def rust_runtime_managed() -> bool: 

305 """Return whether the gateway expects to manage the Rust MCP sidecar locally. 

306 

307 Returns: 

308 ``True`` when the gateway should launch and supervise the Rust sidecar. 

309 """ 

310 return _rust_runtime_managed() 

311 

312 

313def current_mcp_transport_mount() -> str: 

314 """Return which public ``/mcp`` transport is currently mounted. 

315 

316 Returns: 

317 Runtime label identifying the currently mounted public MCP transport. 

318 """ 

319 return _current_mcp_transport_mount() 

320 

321 

322def should_mount_public_rust_transport() -> bool: 

323 """Return whether public ``/mcp`` should be served directly by Rust. 

324 

325 Returns: 

326 ``True`` only when the Rust runtime is enabled and Rust can safely own 

327 steady-state public MCP session traffic. 

328 """ 

329 return _should_mount_public_rust_transport() 

330 

331 

332def should_use_rust_public_session_stack() -> bool: 

333 """Return whether Rust should own the effective public MCP session stack. 

334 

335 Returns: 

336 ``True`` only when the public MCP transport and session semantics should 

337 stay on the Rust-backed path. 

338 """ 

339 return _should_use_rust_public_session_stack() 

340 

341 

342def current_mcp_runtime_mode() -> str: 

343 """Return the current MCP runtime mode label used for health and UI surfaces. 

344 

345 Returns: 

346 Human-readable runtime mode label for diagnostics and UI reporting. 

347 """ 

348 return _current_mcp_runtime_mode() 

349 

350 

351def current_mcp_session_core_mode() -> str: 

352 """Return which runtime currently owns MCP session metadata. 

353 

354 Returns: 

355 ``"rust"`` when the Rust session core is enabled, otherwise ``"python"``. 

356 """ 

357 return _current_mcp_session_core_mode() 

358 

359 

360def current_mcp_event_store_mode() -> str: 

361 """Return which runtime currently owns MCP resumable event-store semantics. 

362 

363 Returns: 

364 ``"rust"`` when the Rust event store is enabled, otherwise ``"python"``. 

365 """ 

366 return _current_mcp_event_store_mode() 

367 

368 

369def current_mcp_resume_core_mode() -> str: 

370 """Return which runtime currently owns public MCP replay/resume behavior. 

371 

372 Returns: 

373 ``"rust"`` when Rust owns replay/resume, otherwise ``"python"``. 

374 """ 

375 return _current_mcp_resume_core_mode() 

376 

377 

378def current_mcp_live_stream_core_mode() -> str: 

379 """Return which runtime currently owns non-resume public GET ``/mcp`` SSE behavior. 

380 

381 Returns: 

382 ``"rust"`` when Rust owns live GET ``/mcp`` streaming, otherwise ``"python"``. 

383 """ 

384 return _current_mcp_live_stream_core_mode() 

385 

386 

387def current_mcp_affinity_core_mode() -> str: 

388 """Return which runtime currently owns MCP multi-worker session-affinity forwarding. 

389 

390 Returns: 

391 ``"rust"`` when Rust owns session-affinity forwarding, otherwise ``"python"``. 

392 """ 

393 return _current_mcp_affinity_core_mode() 

394 

395 

396def current_mcp_session_auth_reuse_mode() -> str: 

397 """Return which runtime currently owns MCP session-bound auth-context reuse. 

398 

399 Returns: 

400 ``"rust"`` when Rust session auth reuse is enabled, otherwise ``"python"``. 

401 """ 

402 return _current_mcp_session_auth_reuse_mode() 

403 

404 

405def mcp_runtime_status_payload() -> Dict[str, Any]: 

406 """Return MCP runtime diagnostics for health, UI, and version surfaces. 

407 

408 Returns: 

409 Diagnostic payload describing the active MCP runtime configuration. 

410 """ 

411 return _mcp_runtime_status_payload() 

412 

413 

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

415 """Identify if an environment variable key likely represents a secret. 

416 

417 Checks if the given environment variable name contains common secret-related 

418 keywords or matches specific patterns to prevent accidental exposure of 

419 sensitive information in diagnostics. 

420 

421 Args: 

422 key (str): The environment variable name to check. 

423 

424 Returns: 

425 bool: True if the key contains secret-looking keywords or matches 

426 known secret patterns, False otherwise. 

427 

428 Examples: 

429 >>> _is_secret("DATABASE_PASSWORD") 

430 True 

431 >>> _is_secret("API_KEY") 

432 True 

433 >>> _is_secret("SECRET_TOKEN") 

434 True 

435 >>> _is_secret("PASS_PHRASE") 

436 True 

437 >>> # Specific ContextForge secrets 

438 >>> _is_secret("BASIC_AUTH_USER") 

439 True 

440 >>> _is_secret("BASIC_AUTH_PASSWORD") 

441 True 

442 >>> _is_secret("JWT_SECRET_KEY") 

443 True 

444 >>> _is_secret("AUTH_ENCRYPTION_SECRET") 

445 True 

446 >>> _is_secret("DATABASE_URL") 

447 True 

448 >>> _is_secret("REDIS_URL") 

449 True 

450 >>> # Non-secrets 

451 >>> _is_secret("HOSTNAME") 

452 False 

453 >>> _is_secret("PORT") 

454 False 

455 >>> _is_secret("DEBUG") 

456 False 

457 >>> _is_secret("APP_NAME") 

458 False 

459 >>> # Case insensitive check 

460 >>> _is_secret("database_password") 

461 True 

462 >>> _is_secret("MySecretKey") 

463 True 

464 >>> _is_secret("basic_auth_user") 

465 True 

466 >>> _is_secret("redis_url") 

467 True 

468 """ 

469 key_upper = key.upper() 

470 

471 # Check for common secret keywords 

472 if any(tok in key_upper for tok in ("SECRET", "TOKEN", "PASS", "KEY")): 

473 return True 

474 

475 # Check for specific secret environment variables 

476 secret_vars = {"BASIC_AUTH_USER", "DATABASE_URL", "REDIS_URL"} 

477 

478 return key_upper in secret_vars 

479 

480 

481_PUBLIC_ENV_PREFIXES = ("MCPGATEWAY_", "MCP_") 

482_PUBLIC_ENV_ALLOWLIST = frozenset( 

483 { 

484 "PORT", 

485 "HOST", 

486 "RELOAD", 

487 "LOG_LEVEL", 

488 "LOG_TO_FILE", 

489 "PLUGINS_ENABLED", 

490 "OBSERVABILITY_ENABLED", 

491 "AUTH_REQUIRED", 

492 "ALLOWED_ORIGINS", 

493 } 

494) 

495 

496 

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

498 """Collect application-specific environment variables for diagnostics. 

499 

500 Only returns variables with ``MCPGATEWAY_`` or ``MCP_`` prefixes, plus a 

501 curated allowlist of safe operational variables. Secrets are still excluded 

502 via :func:`_is_secret`. 

503 

504 Returns: 

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

506 

507 Examples: 

508 >>> import os 

509 >>> original_env = dict(os.environ) 

510 >>> os.environ.clear() 

511 >>> os.environ.update({ 

512 ... "HOME": "/home/user", 

513 ... "PATH": "/usr/bin:/bin", 

514 ... "PORT": "8080", 

515 ... "HOST": "0.0.0.0", 

516 ... "MCPGATEWAY_UI_ENABLED": "true", 

517 ... "MCP_REQUIRE_AUTH": "true", 

518 ... "DATABASE_PASSWORD": "xxxxx", 

519 ... "JWT_SECRET_KEY": "xxxxx", 

520 ... "DATABASE_URL": "postgresql://user:xxxxx@localhost/db", 

521 ... }) 

522 >>> 

523 >>> result = _public_env() 

524 >>> # App-prefixed vars included 

525 >>> "MCPGATEWAY_UI_ENABLED" in result 

526 True 

527 >>> "MCP_REQUIRE_AUTH" in result 

528 True 

529 >>> # Allowlisted vars included 

530 >>> "PORT" in result 

531 True 

532 >>> "HOST" in result 

533 True 

534 >>> # System vars excluded 

535 >>> "HOME" in result 

536 False 

537 >>> "PATH" in result 

538 False 

539 >>> # Secrets still excluded 

540 >>> "DATABASE_PASSWORD" in result 

541 False 

542 >>> "JWT_SECRET_KEY" in result 

543 False 

544 >>> "DATABASE_URL" in result 

545 False 

546 >>> 

547 >>> os.environ.clear() 

548 >>> os.environ.update(original_env) 

549 """ 

550 return {k: v for k, v in os.environ.items() if not _is_secret(k) and (k.upper().startswith(_PUBLIC_ENV_PREFIXES) or k.upper() in _PUBLIC_ENV_ALLOWLIST)} 

551 

552 

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

554 """Redact credentials from a URL for safe display. 

555 

556 Removes password component from URLs while preserving username and other 

557 components. Useful for displaying connection strings in logs or diagnostics 

558 without exposing sensitive credentials. 

559 

560 Args: 

561 url (Optional[str]): The URL to sanitize, may be None. 

562 

563 Returns: 

564 Optional[str]: The sanitized URL with password removed, or None if input was None. 

565 

566 Examples: 

567 >>> _sanitize_url(None) 

568 

569 >>> _sanitize_url("") 

570 

571 >>> # Basic URL without credentials 

572 >>> _sanitize_url("http://localhost:8080/path") 

573 'http://localhost:8080/path' 

574 

575 >>> # URL with username and password 

576 >>> _sanitize_url("postgresql://user:xxxxx@localhost:5432/db") 

577 'postgresql://user@localhost:5432/db' 

578 

579 >>> # Redis URL with auth 

580 >>> _sanitize_url("redis://admin:xxxxx@redis.example.com:6379/0") 

581 'redis://admin@redis.example.com:6379/0' 

582 

583 >>> # URL with only password (no username) 

584 >>> _sanitize_url("redis://:xxxxx@localhost:6379") 

585 'redis://localhost:6379' 

586 

587 """ 

588 if not url: 

589 return None 

590 parts = urlsplit(url) 

591 if parts.password: 

592 # Only include username@ if username exists 

593 if parts.username: 

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

595 else: 

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

597 parts = parts._replace(netloc=netloc) 

598 result = urlunsplit(parts) 

599 return result if isinstance(result, str) else str(result) 

600 

601 

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

603 """Query the database server version. 

604 

605 Attempts to connect to the configured database and retrieve its version string. 

606 Uses dialect-specific queries for accurate version information. 

607 

608 Returns: 

609 tuple[str, bool]: A tuple containing: 

610 - str: Version string on success, or error message on failure 

611 - bool: True if database is reachable, False otherwise 

612 

613 Examples: 

614 >>> from unittest.mock import Mock, patch, MagicMock 

615 >>> 

616 >>> # Test successful SQLite connection 

617 >>> mock_engine = Mock() 

618 >>> mock_engine.dialect.name = "sqlite" 

619 >>> mock_conn = Mock() 

620 >>> mock_result = Mock() 

621 >>> mock_result.scalar.return_value = "3.39.2" 

622 >>> mock_conn.execute.return_value = mock_result 

623 >>> mock_conn.__enter__ = Mock(return_value=mock_conn) 

624 >>> mock_conn.__exit__ = Mock(return_value=None) 

625 >>> mock_engine.connect.return_value = mock_conn 

626 >>> 

627 >>> with patch('mcpgateway.version.engine', mock_engine): 

628 ... version, reachable = _database_version() 

629 >>> version 

630 '3.39.2' 

631 >>> reachable 

632 True 

633 

634 >>> # Test PostgreSQL 

635 >>> mock_engine.dialect.name = "postgresql" 

636 >>> mock_result.scalar.return_value = "14.5" 

637 >>> with patch('mcpgateway.version.engine', mock_engine): 

638 ... version, reachable = _database_version() 

639 >>> version 

640 '14.5' 

641 >>> reachable 

642 True 

643 

644 >>> # Test connection failure 

645 >>> mock_engine.connect.side_effect = Exception("Connection refused") 

646 >>> with patch('mcpgateway.version.engine', mock_engine): 

647 ... version, reachable = _database_version() 

648 >>> version 

649 'Connection refused' 

650 >>> reachable 

651 False 

652 """ 

653 dialect = engine.dialect.name 

654 stmts = { 

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

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

657 } 

658 stmt = stmts.get(dialect, "XXSELECT version();XX") 

659 try: 

660 with engine.connect() as conn: 

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

662 return str(ver), True 

663 except Exception as exc: 

664 return str(exc), False 

665 

666 

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

668 """Gather system-wide and per-process metrics using psutil. 

669 

670 Collects comprehensive system and process metrics with graceful fallbacks 

671 when psutil is not installed or certain APIs are unavailable (e.g., on Windows). 

672 

673 Returns: 

674 Dict[str, Any]: A dictionary containing system and process metrics including: 

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

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

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

678 - cpu_freq_mhz (float | None): Current CPU frequency in MHz (if available). 

679 - load_avg (Tuple[float | None, float | None, float | None]): System load average over 1, 5, and 15 minutes, 

680 or (None, None, None) if unsupported. 

681 - mem_total_mb (float): Total physical memory in MB. 

682 - mem_used_mb (float): Used physical memory in MB. 

683 - swap_total_mb (float): Total swap memory in MB. 

684 - swap_used_mb (float): Used swap memory in MB. 

685 - disk_total_gb (float): Total size of the root partition in GB. 

686 - disk_used_gb (float): Used space on the root partition in GB. 

687 - process (Dict[str, Any]): Dictionary containing metrics for the current process: 

688 - pid (int): Current process ID. 

689 - threads (int): Number of active threads. 

690 - rss_mb (float): Resident Set Size memory usage in MB. 

691 - vms_mb (float): Virtual Memory Size usage in MB. 

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

693 - proc_cpu_percent (float): CPU utilization percentage for the current process. 

694 

695 Returns empty dict if psutil is not installed. 

696 

697 Examples: 

698 >>> from unittest.mock import Mock, patch 

699 >>> 

700 >>> # Test without psutil 

701 >>> with patch('mcpgateway.version.psutil', None): 

702 ... metrics = _system_metrics() 

703 >>> metrics 

704 {} 

705 

706 >>> # Test with mocked psutil 

707 >>> mock_psutil = Mock() 

708 >>> mock_vm = Mock(total=8589934592, used=4294967296) # 8GB total, 4GB used 

709 >>> mock_swap = Mock(total=2147483648, used=1073741824) # 2GB total, 1GB used 

710 >>> mock_freq = Mock(current=2400.0) 

711 >>> mock_disk = Mock(total=107374182400, used=53687091200) # 100GB total, 50GB used 

712 >>> mock_mem_info = Mock(rss=104857600, vms=209715200) # 100MB RSS, 200MB VMS 

713 >>> mock_process = Mock() 

714 >>> mock_process.memory_info.return_value = mock_mem_info 

715 >>> mock_process.num_fds.return_value = 42 

716 >>> mock_process.cpu_percent.return_value = 25.5 

717 >>> mock_process.num_threads.return_value = 4 

718 >>> mock_process.pid = 1234 

719 >>> 

720 >>> mock_psutil.virtual_memory.return_value = mock_vm 

721 >>> mock_psutil.swap_memory.return_value = mock_swap 

722 >>> mock_psutil.cpu_freq.return_value = mock_freq 

723 >>> mock_psutil.cpu_percent.return_value = 45.2 

724 >>> mock_psutil.cpu_count.return_value = 8 

725 >>> mock_psutil.Process.return_value = mock_process 

726 >>> mock_psutil.disk_usage.return_value = mock_disk 

727 >>> mock_psutil.boot_time.return_value = 1640995200.0 # 2022-01-01 00:00:00 UTC 

728 >>> 

729 >>> with patch('mcpgateway.version.psutil', mock_psutil): 

730 ... with patch('os.getloadavg', return_value=(1.5, 2.0, 1.75)): 

731 ... with patch('os.name', 'posix'): 

732 ... metrics = _system_metrics() 

733 >>> 

734 >>> metrics['cpu_percent'] 

735 45.2 

736 >>> metrics['cpu_count'] 

737 8 

738 >>> metrics['cpu_freq_mhz'] 

739 2400 

740 >>> metrics['load_avg'] 

741 (1.5, 2.0, 1.75) 

742 >>> metrics['mem_total_mb'] 

743 8192 

744 >>> metrics['mem_used_mb'] 

745 4096 

746 >>> metrics['process']['pid'] 

747 1234 

748 >>> metrics['process']['threads'] 

749 4 

750 >>> metrics['process']['rss_mb'] 

751 100.0 

752 >>> metrics['process']['open_fds'] 

753 42 

754 """ 

755 if not psutil: 

756 return {} 

757 

758 # System memory and swap 

759 vm = psutil.virtual_memory() 

760 swap = psutil.swap_memory() 

761 

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

763 try: 

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

765 except (AttributeError, OSError): 

766 load = (None, None, None) 

767 

768 # CPU metrics 

769 freq = psutil.cpu_freq() 

770 cpu_pct = psutil.cpu_percent(interval=0.3) 

771 cpu_count = psutil.cpu_count(logical=True) 

772 

773 # Process metrics 

774 proc: "psutil.Process" = psutil.Process() 

775 try: 

776 open_fds = proc.num_fds() 

777 except Exception: 

778 open_fds = None 

779 proc_cpu_pct = proc.cpu_percent(interval=0.1) 

780 memory_info = getattr(proc, "memory_info")() 

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

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

783 threads = proc.num_threads() 

784 pid = proc.pid 

785 

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

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

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

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

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

791 

792 return { 

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

794 "cpu_percent": cpu_pct, 

795 "cpu_count": cpu_count, 

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

797 "load_avg": load, 

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

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

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

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

802 "disk_total_gb": disk_total_gb, 

803 "disk_used_gb": disk_used_gb, 

804 "process": { 

805 "pid": pid, 

806 "threads": threads, 

807 "rss_mb": rss_mb, 

808 "vms_mb": vms_mb, 

809 "open_fds": open_fds, 

810 "proc_cpu_percent": proc_cpu_pct, 

811 }, 

812 } 

813 

814 

815def _build_payload( 

816 redis_version: Optional[str], 

817 redis_ok: bool, 

818) -> Dict[str, Any]: 

819 """Build the complete diagnostics payload. 

820 

821 Assembles all diagnostic information into a structured dictionary suitable 

822 for JSON serialization or HTML rendering. 

823 

824 Args: 

825 redis_version (Optional[str]): Redis version string or error message. 

826 redis_ok (bool): Whether Redis is reachable and operational. 

827 

828 Returns: 

829 Dict[str, Any]: Complete diagnostics payload containing timestamp, host info, 

830 application details, platform info, database and Redis status, settings, 

831 environment variables, and system metrics. 

832 """ 

833 db_ver, db_ok = _database_version() 

834 return { 

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

836 "host": HOSTNAME, 

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

838 "app": { 

839 "name": settings.app_name, 

840 "version": __version__, 

841 "mcp_protocol_version": settings.protocol_version, 

842 }, 

843 "platform": { 

844 "python": platform.python_version(), 

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

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

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

848 }, 

849 "database": { 

850 "dialect": engine.dialect.name, 

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

852 "reachable": db_ok, 

853 "server_version": db_ver, 

854 }, 

855 "redis": { 

856 "available": REDIS_AVAILABLE, 

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

858 "reachable": redis_ok, 

859 "server_version": redis_version, 

860 }, 

861 "settings": { 

862 "cache_type": settings.cache_type, 

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

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

865 "metrics_retention_days": getattr(settings, "metrics_retention_days", 30), 

866 "metrics_rollup_retention_days": getattr(settings, "metrics_rollup_retention_days", 365), 

867 "metrics_cleanup_enabled": getattr(settings, "metrics_cleanup_enabled", True), 

868 "metrics_rollup_enabled": getattr(settings, "metrics_rollup_enabled", True), 

869 }, 

870 "mcp_runtime": _mcp_runtime_status_payload(), 

871 "env": _public_env(), 

872 "system": _system_metrics(), 

873 } 

874 

875 

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

877 """Render a dict as an HTML table. 

878 

879 Converts a dictionary into an HTML table with keys as headers and values 

880 as cells. Non-string values are JSON-serialized for display. 

881 

882 Args: 

883 obj (Dict[str, Any]): The dictionary to render as a table. 

884 

885 Returns: 

886 str: HTML table markup string. 

887 

888 Examples: 

889 >>> # Simple string values 

890 >>> html = _html_table({"name": "test", "version": "1.0"}) 

891 >>> '<table>' in html 

892 True 

893 >>> '<tr><th>name</th><td>test</td></tr>' in html 

894 True 

895 >>> '<tr><th>version</th><td>1.0</td></tr>' in html 

896 True 

897 

898 >>> # Complex values get JSON serialized 

899 >>> html = _html_table({"count": 42, "active": True, "items": ["a", "b"]}) 

900 >>> '<th>count</th><td>42</td>' in html 

901 True 

902 >>> '<th>active</th><td>true</td>' in html 

903 True 

904 >>> '<th>items</th><td>["a","b"]</td>' in html 

905 True 

906 

907 >>> # Empty dict 

908 >>> _html_table({}) 

909 '<table></table>' 

910 """ 

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

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

913 

914 

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

916 """Render the full diagnostics payload as HTML. 

917 

918 Creates a complete HTML page with styled tables displaying all diagnostic 

919 information in a user-friendly format. 

920 

921 Args: 

922 payload (Dict[str, Any]): The complete diagnostics data structure. 

923 

924 Returns: 

925 str: Complete HTML page as a string. 

926 

927 Examples: 

928 >>> payload = { 

929 ... "timestamp": "2024-01-01T00:00:00Z", 

930 ... "host": "test-server", 

931 ... "uptime_seconds": 3600, 

932 ... "app": {"name": "TestApp", "version": "1.0"}, 

933 ... "platform": {"python": "3.9.0"}, 

934 ... "database": {"dialect": "sqlite", "reachable": True}, 

935 ... "redis": {"available": False}, 

936 ... "settings": {"cache_type": "memory"}, 

937 ... "mcp_runtime": {"mode": "python", "mounted": "python"}, 

938 ... "system": {"cpu_count": 4}, 

939 ... "env": {"PATH": "/usr/bin"} 

940 ... } 

941 >>> 

942 >>> html = _render_html(payload) 

943 >>> '<!doctype html>' in html 

944 True 

945 >>> '<h1>ContextForge diagnostics</h1>' in html 

946 True 

947 >>> 'test-server' in html 

948 True 

949 >>> '3600s' in html 

950 True 

951 >>> '<h2>App</h2>' in html 

952 True 

953 >>> '<h2>Database</h2>' in html 

954 True 

955 >>> '<h2>MCP Runtime</h2>' in html 

956 True 

957 >>> '<style>' in html 

958 True 

959 >>> 'border-collapse:collapse' in html 

960 True 

961 """ 

962 style = ( 

963 "<style>" 

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

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

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

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

968 "</style>" 

969 ) 

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

971 sections = "" 

972 for title, key in ( 

973 ("App", "app"), 

974 ("Platform", "platform"), 

975 ("Database", "database"), 

976 ("Redis", "redis"), 

977 ("Settings", "settings"), 

978 ("MCP Runtime", "mcp_runtime"), 

979 ("System", "system"), 

980 ): 

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

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

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

984 

985 

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

987 """Render the login form HTML for unauthenticated browsers. 

988 

989 Creates a simple login form that posts credentials and redirects back 

990 to the requested URL after successful authentication. 

991 

992 Args: 

993 next_url (str): The URL to redirect to after successful login. 

994 

995 Returns: 

996 str: HTML string containing the complete login page. 

997 

998 Examples: 

999 >>> html = _login_html("/version?format=html") 

1000 >>> '<!doctype html>' in html 

1001 True 

1002 >>> '<h2>Please log in</h2>' in html 

1003 True 

1004 >>> 'action="/login"' in html 

1005 True 

1006 >>> 'name="next" value="/version?format=html"' in html 

1007 True 

1008 >>> 'type="text" name="username"' in html 

1009 True 

1010 >>> 'type="password" name="password"' in html 

1011 True 

1012 >>> 'autocomplete="username"' in html 

1013 True 

1014 >>> 'autocomplete="current-password"' in html 

1015 True 

1016 >>> '<button type="submit">Login</button>' in html 

1017 True 

1018 """ 

1019 return f"""<!doctype html> 

1020<html><head><meta charset='utf-8'><title>Login - ContextForge</title> 

1021<style> 

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

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

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

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

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

1027</style></head> 

1028<body> 

1029 <h2>Please log in</h2> 

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

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

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

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

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

1035 </form> 

1036</body></html>""" 

1037 

1038 

1039# Endpoint 

1040@router.get("/version", summary="Diagnostics (admin only)") 

1041async def version_endpoint( 

1042 request: Request, 

1043 fmt: Optional[str] = None, 

1044 partial: Optional[bool] = False, 

1045 _user=Depends(require_admin_auth), 

1046) -> Response: 

1047 """Serve diagnostics as JSON, full HTML, or partial HTML. 

1048 

1049 Main endpoint that gathers all diagnostic information and returns it in the 

1050 requested format. Requires admin authentication. 

1051 

1052 The endpoint supports three output formats: 

1053 - JSON (default): Machine-readable diagnostic data 

1054 - Full HTML: Complete HTML page with styled tables 

1055 - Partial HTML: HTML fragment for embedding (when partial=True) 

1056 

1057 Args: 

1058 request (Request): The incoming FastAPI request object. 

1059 fmt (Optional[str]): Query parameter to force format ('html' for HTML output). 

1060 partial (Optional[bool]): Query parameter to request partial HTML fragment. 

1061 _user: Injected authenticated admin user from require_admin_auth dependency. 

1062 

1063 Returns: 

1064 Response: JSONResponse with diagnostic data, or HTMLResponse with formatted page. 

1065 

1066 Examples: 

1067 >>> import asyncio 

1068 >>> from unittest.mock import Mock, AsyncMock, patch 

1069 >>> from fastapi import Request 

1070 >>> from fastapi.responses import JSONResponse, HTMLResponse 

1071 >>> 

1072 >>> # Create mock request 

1073 >>> mock_request = Mock(spec=Request) 

1074 >>> mock_request.headers = {"accept": "application/json"} 

1075 >>> 

1076 >>> # Test JSON response (default) 

1077 >>> async def test_json(): 

1078 ... with patch('mcpgateway.version.REDIS_AVAILABLE', False): 

1079 ... with patch('mcpgateway.version._build_payload') as mock_build: 

1080 ... mock_build.return_value = {"test": "data"} 

1081 ... response = await version_endpoint(mock_request, fmt=None, partial=False, _user="testuser") 

1082 ... return response 

1083 >>> 

1084 >>> response = asyncio.run(test_json()) 

1085 >>> isinstance(response, JSONResponse) 

1086 True 

1087 

1088 >>> # Test HTML response with fmt parameter 

1089 >>> async def test_html_fmt(): 

1090 ... with patch('mcpgateway.version.REDIS_AVAILABLE', False): 

1091 ... with patch('mcpgateway.version._build_payload') as mock_build: 

1092 ... with patch('mcpgateway.version._render_html') as mock_render: 

1093 ... mock_build.return_value = {"test": "data"} 

1094 ... mock_render.return_value = "<html>test</html>" 

1095 ... response = await version_endpoint(mock_request, fmt="html", partial=False, _user="testuser") 

1096 ... return response 

1097 >>> 

1098 >>> response = asyncio.run(test_html_fmt()) 

1099 >>> isinstance(response, HTMLResponse) 

1100 True 

1101 

1102 >>> # Test with Redis available (using is_redis_available and get_redis_client) 

1103 >>> async def test_with_redis(): 

1104 ... from mcpgateway.utils.redis_client import _reset_client 

1105 ... _reset_client() # Reset shared client state for clean test 

1106 ... mock_redis = AsyncMock() 

1107 ... mock_redis.info = AsyncMock(return_value={"redis_version": "7.0.5"}) 

1108 ... 

1109 ... async def mock_get_redis_client(): 

1110 ... return mock_redis 

1111 ... 

1112 ... async def mock_is_redis_available(): 

1113 ... return True 

1114 ... 

1115 ... with patch('mcpgateway.version.REDIS_AVAILABLE', True): 

1116 ... with patch('mcpgateway.version.settings') as mock_settings: 

1117 ... mock_settings.cache_type = "redis" 

1118 ... mock_settings.redis_url = "redis://localhost:6379" 

1119 ... with patch('mcpgateway.version.is_redis_available', mock_is_redis_available): 

1120 ... with patch('mcpgateway.version.get_redis_client', mock_get_redis_client): 

1121 ... with patch('mcpgateway.version._build_payload') as mock_build: 

1122 ... mock_build.return_value = {"redis": {"version": "7.0.5"}} 

1123 ... response = await version_endpoint(mock_request, _user="testuser") 

1124 ... # Verify Redis info was retrieved 

1125 ... mock_redis.info.assert_called_once() 

1126 ... # Verify payload was built with Redis info 

1127 ... mock_build.assert_called_once_with("7.0.5", True) 

1128 ... _reset_client() # Clean up after test 

1129 ... return response 

1130 >>> 

1131 >>> response = asyncio.run(test_with_redis()) 

1132 >>> isinstance(response, JSONResponse) 

1133 True 

1134 """ 

1135 # Redis health check - use shared client from factory 

1136 redis_ok = False 

1137 redis_version: Optional[str] = None 

1138 if REDIS_AVAILABLE and settings.cache_type.lower() == "redis" and settings.redis_url: 

1139 try: 

1140 # Use centralized availability check 

1141 redis_ok = await is_redis_available() 

1142 if redis_ok: 

1143 client = await get_redis_client() 

1144 if client: 

1145 info = await asyncio.wait_for(client.info(), timeout=3.0) 

1146 redis_version = info.get("redis_version", "unknown") 

1147 else: 

1148 redis_version = "Client not available" 

1149 else: 

1150 redis_version = "Not reachable" 

1151 except Exception as exc: 

1152 redis_ok = False 

1153 redis_version = str(exc) 

1154 

1155 payload = _build_payload(redis_version, redis_ok) 

1156 if partial: 

1157 # Return partial HTML fragment for HTMX embedding 

1158 templates = getattr(request.app.state, "templates", None) 

1159 if templates is None: 

1160 jinja_env = Environment( 

1161 loader=FileSystemLoader(str(settings.templates_dir)), 

1162 autoescape=True, 

1163 auto_reload=settings.templates_auto_reload, 

1164 ) 

1165 templates = Jinja2Templates(env=jinja_env) 

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

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

1168 if wants_html: 

1169 return HTMLResponse(_render_html(payload)) 

1170 return ORJSONResponse(payload)