Coverage for mcpgateway / version.py: 99%
220 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-06 00:56 +0100
« 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
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
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
18The module provides comprehensive system diagnostics including application info,
19platform details, database and Redis connectivity, system metrics, and environment
20variables (with secrets redacted).
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
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"""
50# Future
51from __future__ import annotations
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
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
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
80# Optional runtime dependencies
81try:
82 # Third-Party
83 import psutil # optional for enhanced metrics
84except ImportError:
85 psutil = None # type: ignore
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
95 logging.getLogger(__name__).warning(f"Redis module check failed ({type(e).__name__}: {e}), Redis support disabled")
96 REDIS_AVAILABLE = False
98# Globals
100START_TIME = time.time()
101HOSTNAME = socket.gethostname()
102LOGIN_PATH = "/login"
103router = APIRouter(tags=["meta"])
106def _env_flag(name: str, default: bool = False) -> bool:
107 """Read a boolean environment variable using common truthy spellings.
109 Args:
110 name: Environment variable name.
111 default: Default value used when the variable is unset.
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"}
122def _rust_build_included() -> bool:
123 """Return whether the current image includes Rust MCP artifacts.
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)
131def _rust_runtime_managed() -> bool:
132 """Return whether the gateway expects to manage the Rust MCP sidecar locally.
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)
140def _current_mcp_transport_mount() -> str:
141 """Return which public ``/mcp`` transport is currently mounted.
143 Returns:
144 Runtime label identifying the currently mounted public MCP transport.
145 """
146 return "rust" if _should_mount_public_rust_transport() else "python"
149def _should_mount_public_rust_transport() -> bool:
150 """Return whether public ``/mcp`` should be served directly by Rust.
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)
159def _should_use_rust_public_session_stack() -> bool:
160 """Return whether Rust should own the effective public MCP session stack.
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()
169def _current_mcp_runtime_mode() -> str:
170 """Return the current MCP runtime mode label used for health and UI surfaces.
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"
182def _current_mcp_session_core_mode() -> str:
183 """Return which runtime currently owns MCP session metadata.
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"
193def _current_mcp_event_store_mode() -> str:
194 """Return which runtime currently owns MCP resumable event-store semantics.
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"
204def _current_mcp_resume_core_mode() -> str:
205 """Return which runtime currently owns public MCP replay/resume behavior.
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"
220def _current_mcp_live_stream_core_mode() -> str:
221 """Return which runtime currently owns non-resume public GET ``/mcp`` SSE behavior.
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"
231def _current_mcp_affinity_core_mode() -> str:
232 """Return which runtime currently owns MCP multi-worker session-affinity forwarding.
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"
242def _current_mcp_session_auth_reuse_mode() -> str:
243 """Return which runtime currently owns MCP session-bound auth-context reuse.
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"
253def _mcp_runtime_status_payload() -> Dict[str, Any]:
254 """Return MCP runtime diagnostics for health, UI, and version surfaces.
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 }
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
292 return payload
295def rust_build_included() -> bool:
296 """Return whether the current image includes Rust MCP artifacts.
298 Returns:
299 ``True`` when the current image contains the Rust MCP binaries/plugins.
300 """
301 return _rust_build_included()
304def rust_runtime_managed() -> bool:
305 """Return whether the gateway expects to manage the Rust MCP sidecar locally.
307 Returns:
308 ``True`` when the gateway should launch and supervise the Rust sidecar.
309 """
310 return _rust_runtime_managed()
313def current_mcp_transport_mount() -> str:
314 """Return which public ``/mcp`` transport is currently mounted.
316 Returns:
317 Runtime label identifying the currently mounted public MCP transport.
318 """
319 return _current_mcp_transport_mount()
322def should_mount_public_rust_transport() -> bool:
323 """Return whether public ``/mcp`` should be served directly by Rust.
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()
332def should_use_rust_public_session_stack() -> bool:
333 """Return whether Rust should own the effective public MCP session stack.
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()
342def current_mcp_runtime_mode() -> str:
343 """Return the current MCP runtime mode label used for health and UI surfaces.
345 Returns:
346 Human-readable runtime mode label for diagnostics and UI reporting.
347 """
348 return _current_mcp_runtime_mode()
351def current_mcp_session_core_mode() -> str:
352 """Return which runtime currently owns MCP session metadata.
354 Returns:
355 ``"rust"`` when the Rust session core is enabled, otherwise ``"python"``.
356 """
357 return _current_mcp_session_core_mode()
360def current_mcp_event_store_mode() -> str:
361 """Return which runtime currently owns MCP resumable event-store semantics.
363 Returns:
364 ``"rust"`` when the Rust event store is enabled, otherwise ``"python"``.
365 """
366 return _current_mcp_event_store_mode()
369def current_mcp_resume_core_mode() -> str:
370 """Return which runtime currently owns public MCP replay/resume behavior.
372 Returns:
373 ``"rust"`` when Rust owns replay/resume, otherwise ``"python"``.
374 """
375 return _current_mcp_resume_core_mode()
378def current_mcp_live_stream_core_mode() -> str:
379 """Return which runtime currently owns non-resume public GET ``/mcp`` SSE behavior.
381 Returns:
382 ``"rust"`` when Rust owns live GET ``/mcp`` streaming, otherwise ``"python"``.
383 """
384 return _current_mcp_live_stream_core_mode()
387def current_mcp_affinity_core_mode() -> str:
388 """Return which runtime currently owns MCP multi-worker session-affinity forwarding.
390 Returns:
391 ``"rust"`` when Rust owns session-affinity forwarding, otherwise ``"python"``.
392 """
393 return _current_mcp_affinity_core_mode()
396def current_mcp_session_auth_reuse_mode() -> str:
397 """Return which runtime currently owns MCP session-bound auth-context reuse.
399 Returns:
400 ``"rust"`` when Rust session auth reuse is enabled, otherwise ``"python"``.
401 """
402 return _current_mcp_session_auth_reuse_mode()
405def mcp_runtime_status_payload() -> Dict[str, Any]:
406 """Return MCP runtime diagnostics for health, UI, and version surfaces.
408 Returns:
409 Diagnostic payload describing the active MCP runtime configuration.
410 """
411 return _mcp_runtime_status_payload()
414def _is_secret(key: str) -> bool:
415 """Identify if an environment variable key likely represents a secret.
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.
421 Args:
422 key (str): The environment variable name to check.
424 Returns:
425 bool: True if the key contains secret-looking keywords or matches
426 known secret patterns, False otherwise.
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()
471 # Check for common secret keywords
472 if any(tok in key_upper for tok in ("SECRET", "TOKEN", "PASS", "KEY")):
473 return True
475 # Check for specific secret environment variables
476 secret_vars = {"BASIC_AUTH_USER", "DATABASE_URL", "REDIS_URL"}
478 return key_upper in secret_vars
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)
497def _public_env() -> Dict[str, str]:
498 """Collect application-specific environment variables for diagnostics.
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`.
504 Returns:
505 Dict[str, str]: A map of environment variable names to values.
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)}
553def _sanitize_url(url: Optional[str]) -> Optional[str]:
554 """Redact credentials from a URL for safe display.
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.
560 Args:
561 url (Optional[str]): The URL to sanitize, may be None.
563 Returns:
564 Optional[str]: The sanitized URL with password removed, or None if input was None.
566 Examples:
567 >>> _sanitize_url(None)
569 >>> _sanitize_url("")
571 >>> # Basic URL without credentials
572 >>> _sanitize_url("http://localhost:8080/path")
573 'http://localhost:8080/path'
575 >>> # URL with username and password
576 >>> _sanitize_url("postgresql://user:xxxxx@localhost:5432/db")
577 'postgresql://user@localhost:5432/db'
579 >>> # Redis URL with auth
580 >>> _sanitize_url("redis://admin:xxxxx@redis.example.com:6379/0")
581 'redis://admin@redis.example.com:6379/0'
583 >>> # URL with only password (no username)
584 >>> _sanitize_url("redis://:xxxxx@localhost:6379")
585 'redis://localhost:6379'
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)
602def _database_version() -> tuple[str, bool]:
603 """Query the database server version.
605 Attempts to connect to the configured database and retrieve its version string.
606 Uses dialect-specific queries for accurate version information.
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
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
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
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
667def _system_metrics() -> Dict[str, Any]:
668 """Gather system-wide and per-process metrics using psutil.
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).
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.
695 Returns empty dict if psutil is not installed.
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 {}
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 {}
758 # System memory and swap
759 vm = psutil.virtual_memory()
760 swap = psutil.swap_memory()
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)
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)
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
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)
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 }
815def _build_payload(
816 redis_version: Optional[str],
817 redis_ok: bool,
818) -> Dict[str, Any]:
819 """Build the complete diagnostics payload.
821 Assembles all diagnostic information into a structured dictionary suitable
822 for JSON serialization or HTML rendering.
824 Args:
825 redis_version (Optional[str]): Redis version string or error message.
826 redis_ok (bool): Whether Redis is reachable and operational.
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 }
876def _html_table(obj: Dict[str, Any]) -> str:
877 """Render a dict as an HTML table.
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.
882 Args:
883 obj (Dict[str, Any]): The dictionary to render as a table.
885 Returns:
886 str: HTML table markup string.
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
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
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>"
915def _render_html(payload: Dict[str, Any]) -> str:
916 """Render the full diagnostics payload as HTML.
918 Creates a complete HTML page with styled tables displaying all diagnostic
919 information in a user-friendly format.
921 Args:
922 payload (Dict[str, Any]): The complete diagnostics data structure.
924 Returns:
925 str: Complete HTML page as a string.
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>"
986def _login_html(next_url: str) -> str:
987 """Render the login form HTML for unauthenticated browsers.
989 Creates a simple login form that posts credentials and redirects back
990 to the requested URL after successful authentication.
992 Args:
993 next_url (str): The URL to redirect to after successful login.
995 Returns:
996 str: HTML string containing the complete login page.
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>"""
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.
1049 Main endpoint that gathers all diagnostic information and returns it in the
1050 requested format. Requires admin authentication.
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)
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.
1063 Returns:
1064 Response: JSONResponse with diagnostic data, or HTMLResponse with formatted page.
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
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
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)
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)