Skip to content

Commit 2b0baae

Browse files
committed
Added version info
1 parent e4bdfa9 commit 2b0baae

File tree

7 files changed

+220
-12
lines changed

7 files changed

+220
-12
lines changed

Makefile

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -829,7 +829,7 @@ podman-run-ssl-host: certs
829829
--health-interval=1m --health-retries=3 \
830830
--health-start-period=30s --health-timeout=10s \
831831
-d $(IMG_PROD)
832-
@sleep 2 && podman logs $(PROJECT_NAME) | tail -n +1
832+
@sleep 2 && podman logs $(PROJECT_NAME) | tail -n +1
833833

834834
podman-stop:
835835
@echo "🛑 Stopping podman container…"
@@ -1211,4 +1211,3 @@ ibmcloud-ce-status:
12111211
ibmcloud-ce-rm:
12121212
@echo "🗑️ Deleting Code Engine app: $(IBMCLOUD_CODE_ENGINE_APP)"
12131213
@ibmcloud ce application delete --name $(IBMCLOUD_CODE_ENGINE_APP) -f
1214-

docker-compose.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ services:
6767
start_period: 20s
6868

6969
# volumes:
70-
# - ./certs:/app/certs:ro # mount certs folder read-only
70+
# - ./certs:/app/certs:ro # mount certs folder read-only
7171

7272
###############################################################################
7373
# DATABASES – enable ONE of these blocks and adjust DATABASE_URL
@@ -216,7 +216,7 @@ services:
216216

217217
# environment:
218218
# # ──────────────────────────────────────────────────────────────────────
219-
# # LEGACY HOST LIST (for showing in UI — not used for TLS)
219+
# # LEGACY HOST LIST (for showing in UI — not used for TLS)
220220
# # ──────────────────────────────────────────────────────────────────────
221221
# - REDIS_HOSTS=local:redis:6379
222222

@@ -231,18 +231,18 @@ services:
231231
# - CLUSTER_NO_TLS_VALIDATION=true # <— skip SNI/hostname checks in clusters
232232

233233
# # ──────────────────────────────────────────────────────────────────────
234-
# # SELF-SIGNED: trust no-CA by default
234+
# # SELF-SIGNED: trust no-CA by default
235235
# # ──────────────────────────────────────────────────────────────────────
236236
# - NODE_TLS_REJECT_UNAUTHORIZED=0 # <— Node.js will accept your self-signed cert
237237

238238
# # ──────────────────────────────────────────────────────────────────────
239-
# # HTTP BASIC-AUTH FOR THE WEB UI
239+
# # HTTP BASIC-AUTH FOR THE WEB UI
240240
# # ──────────────────────────────────────────────────────────────────────
241241
# - HTTP_USER=admin # <— change your UI username
242242
# - HTTP_PASSWORD=changeme # <— change your UI password
243243

244244
# # ──────────────────────────────────────────────────────────────────────
245-
# # OPTIONAL: ENABLE REAL CERT VALIDATION (instead of skipping checks)
245+
# # OPTIONAL: ENABLE REAL CERT VALIDATION (instead of skipping checks)
246246
# # ──────────────────────────────────────────────────────────────────────
247247
# # - REDIS_TLS_CA_CERT_FILE=/certs/selfsigned.crt
248248
# # - REDIS_TLS_SERVER_NAME=redis.example.com

docs/docs/deployment/compose.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,5 +140,3 @@ service reachable from Windows and the LAN.
140140
* Health-check gating with `depends_on: condition: service_healthy`
141141
* [UBI9 runtime on Apple Silicon limitations (`x86_64-v2` glibc)](https://github.com/containers/podman/issues/15456)
142142
* General Containerfile build guidance (Fedora/Red Hat)
143-
144-

docs/docs/manage/backup.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,4 +119,3 @@ SELECT content FROM mcp_messages WHERE session_id = 'abc123';
119119
---
120120

121121
These tables are cleaned automatically when session TTLs expire, but can also be purged manually if needed.
122-

docs/docs/testing/basic.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# MCP Gateway - Basic
22

3-
Test script for MCP Gateway development environments.
3+
Test script for MCP Gateway development environments.
44
Verifies API readiness, JWT auth, Gateway/Tool/Server lifecycle, and RPC invocation.
55

66
---

mcpgateway/main.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@
5252
from sqlalchemy.orm import Session
5353
from starlette.middleware.base import BaseHTTPMiddleware
5454

55-
# Import the admin routes from the new module
5655
from mcpgateway.admin import admin_router
5756
from mcpgateway.cache import ResourceCache, SessionRegistry
5857
from mcpgateway.config import jsonpath_modifier, settings
@@ -118,6 +117,9 @@
118117
validate_request,
119118
)
120119

120+
# Import the admin routes from the new module
121+
from mcpgateway.version import router as version_router
122+
121123
# Initialize logging service first
122124
logging_service = LoggingService()
123125
logger = logging_service.get_logger("mcpgateway")
@@ -468,6 +470,7 @@ async def get_server(server_id: int, db: Session = Depends(get_db), user: str =
468470
except ServerNotFoundError as e:
469471
raise HTTPException(status_code=404, detail=str(e))
470472

473+
471474
@server_router.post("", response_model=ServerRead, status_code=201)
472475
@server_router.post("/", response_model=ServerRead, status_code=201)
473476
async def create_server(
@@ -1224,6 +1227,7 @@ async def list_prompts(
12241227
logger.debug(f"User: {user} requested prompt list with include_inactive={include_inactive}, cursor={cursor}")
12251228
return await prompt_service.list_prompts(db, cursor=cursor, include_inactive=include_inactive)
12261229

1230+
12271231
@prompt_router.post("", response_model=PromptRead)
12281232
@prompt_router.post("/", response_model=PromptRead)
12291233
async def create_prompt(
@@ -1522,6 +1526,7 @@ async def list_roots(
15221526
logger.debug(f"User '{user}' requested list of roots")
15231527
return await root_service.list_roots()
15241528

1529+
15251530
@root_router.post("", response_model=Root)
15261531
@root_router.post("/", response_model=Root)
15271532
async def add_root(
@@ -1961,6 +1966,7 @@ async def lifespan() -> AsyncIterator[None]:
19611966
app.mount("/static", StaticFiles(directory=str(settings.static_dir)), name="static")
19621967

19631968
# Include routers
1969+
app.include_router(version_router)
19641970
app.include_router(protocol_router)
19651971
app.include_router(tool_router)
19661972
app.include_router(resource_router)

mcpgateway/version.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
"""version.py - /version endpoint with rich diagnostic & HTML option **requiring authentication**.
2+
3+
Copyright 2025
4+
SPDX-License-Identifier: Apache-2.0
5+
Authors: Mihai Criveti
6+
7+
This module exposes a FastAPI router that returns a structured snapshot of the
8+
running MCP Gateway instance, its dependencies (database, Redis, OS), and key
9+
configuration flags. If the request's *Accept* header includes *text/html* or
10+
`?format=html` is passed, the endpoint will render a simple HTML dashboard; in
11+
all other cases it returns JSON.
12+
13+
Access to this endpoint is protected by the same authentication dependency
14+
(`require_auth`) used elsewhere in the gateway, so callers must supply a valid
15+
Bearer token (or Basic credentials if enabled).
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import json
21+
import os
22+
import platform
23+
import socket
24+
import time
25+
from datetime import datetime
26+
from typing import Any, Dict
27+
28+
import redis.asyncio as aioredis
29+
from fastapi import APIRouter, Depends, Request
30+
from fastapi.responses import HTMLResponse, JSONResponse
31+
from sqlalchemy import text
32+
33+
try:
34+
import psutil # optional - process & system metrics
35+
except ImportError: # pragma: no cover - psutil is optional
36+
psutil = None # type: ignore
37+
38+
from mcpgateway.config import settings
39+
from mcpgateway.db import engine
40+
from mcpgateway.utils.verify_credentials import require_auth
41+
42+
# ──────────────────────────────────────────────────────────────────────────────
43+
# Globals
44+
# ──────────────────────────────────────────────────────────────────────────────
45+
46+
START_TIME = time.time()
47+
HOSTNAME = socket.gethostname()
48+
router = APIRouter(tags=["meta"])
49+
50+
# ──────────────────────────────────────────────────────────────────────────────
51+
# Helper utilities
52+
# ──────────────────────────────────────────────────────────────────────────────
53+
54+
55+
def _is_secret(key: str) -> bool:
56+
"""Heuristic for redacting env‑vars that look secret."""
57+
keywords = ("SECRET", "PASSWORD", "TOKEN", "KEY")
58+
return any(k in key.upper() for k in keywords)
59+
60+
61+
def _public_env() -> Dict[str, str]:
62+
"""Return env‑vars with obvious secrets stripped out."""
63+
return {k: v for k, v in os.environ.items() if not _is_secret(k)}
64+
65+
66+
def _database_version() -> tuple[str | None, bool]:
67+
"""Attempt to fetch RDBMS version string; return (version, reachable)."""
68+
dialect = engine.dialect.name
69+
query_map = {
70+
"sqlite": "SELECT sqlite_version();",
71+
"postgresql": "SELECT current_setting('server_version');",
72+
"mysql": "SELECT version();",
73+
}
74+
query = query_map.get(dialect, "SELECT version();")
75+
try:
76+
with engine.connect() as conn:
77+
return conn.execute(text(query)).scalar() or "unknown", True
78+
except Exception as exc: # noqa: BLE001 - we surface raw error
79+
return str(exc), False
80+
81+
82+
def _system_metrics() -> Dict[str, Any]:
83+
"""Return optional memory/CPU metrics if psutil available."""
84+
if not psutil:
85+
return {}
86+
vm = psutil.virtual_memory()
87+
load1, load5, load15 = os.getloadavg()
88+
return {
89+
"cpu_count": psutil.cpu_count(logical=True),
90+
"load_avg": [load1, load5, load15],
91+
"mem_total_mb": round(vm.total / 1048576),
92+
"mem_used_mb": round(vm.used / 1048576),
93+
}
94+
95+
96+
def _build_payload(redis_version: str | None = None, redis_ok: bool = False) -> Dict[str, Any]:
97+
"""Assemble structured diagnostic payload."""
98+
db_version, db_ok = _database_version()
99+
100+
payload: Dict[str, Any] = {
101+
"timestamp": datetime.utcnow().isoformat() + "Z",
102+
"host": HOSTNAME,
103+
"uptime_seconds": int(time.time() - START_TIME),
104+
"app": {
105+
"name": settings.app_name,
106+
"mcp_protocol_version": settings.protocol_version,
107+
},
108+
"platform": {
109+
"python": platform.python_version(),
110+
"os": f"{platform.system()} {platform.release()} ({platform.machine()})",
111+
"fastapi": __import__("fastapi").__version__,
112+
"sqlalchemy": __import__("sqlalchemy").__version__,
113+
},
114+
"database": {
115+
"dialect": engine.dialect.name,
116+
"url": settings.database_url,
117+
"reachable": db_ok,
118+
"server_version": db_version,
119+
},
120+
"redis": {
121+
"url": settings.redis_url,
122+
"reachable": redis_ok,
123+
"server_version": redis_version,
124+
},
125+
"settings": {
126+
"cache_type": settings.cache_type,
127+
"mcpgateway_ui_enabled": getattr(settings, "mcpgateway_ui_enabled", None),
128+
"mcpgateway_admin_api_enabled": getattr(settings, "mcpgateway_admin_api_enabled", None),
129+
},
130+
"env": _public_env(),
131+
"system": _system_metrics(),
132+
}
133+
return payload
134+
135+
136+
def _render_html(data: Dict[str, Any]) -> str:
137+
"""Very small hand‑rolled HTML template. No Jinja needed."""
138+
139+
def _table(obj: Dict[str, Any]) -> str:
140+
rows = "\n".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())
141+
return f"<table>{rows}</table>"
142+
143+
sections = []
144+
for key in (
145+
"app",
146+
"platform",
147+
"database",
148+
"redis",
149+
"settings",
150+
"system",
151+
):
152+
sections.append(f"<h2>{key.title()}</h2>{_table(data[key])}")
153+
env_rows = "".join(f"<tr><th>{k}</th><td>{v}</td></tr>" for k, v in data["env"].items())
154+
env_table = f"<h2>Environment</h2><table>{env_rows}</table>"
155+
info_hdr = f"<h1>MCP Gateway diagnostics</h1><p>Generated: {data['timestamp']} — Host: {data['host']} — Uptime: {data['uptime_seconds']} s</p>"
156+
style = """
157+
<style>
158+
body{font-family:system-ui,sans-serif;margin:2rem;}
159+
table{border-collapse:collapse;width:100%;margin-bottom:1.5rem;}
160+
th,td{border:1px solid #ccc;padding:0.5rem;text-align:left;font-size:0.9rem;}
161+
th{background:#f7f7f7;width:22%;}
162+
h1{margin-top:0;}
163+
</style>"""
164+
return f"<!doctype html><html><head><meta charset='utf-8'>{style}</head><body>{info_hdr}{''.join(sections)}{env_table}</body></html>"
165+
166+
167+
# ──────────────────────────────────────────────────────────────────────────────
168+
# Endpoint
169+
# ──────────────────────────────────────────────────────────────────────────────
170+
171+
172+
@router.get(
173+
"/version",
174+
summary="Gateway diagnostic & dependency versions (auth required)",
175+
response_class=JSONResponse,
176+
)
177+
async def get_version(
178+
request: Request,
179+
format: str | None = None,
180+
user: str = Depends(require_auth), # 🛡️ enforce authentication
181+
):
182+
"""Return JSON or HTML diagnostic snapshot (authentication required).
183+
184+
**JSON** is default; request HTML via:
185+
- `Accept: text/html` header
186+
- query param `?format=html`
187+
"""
188+
redis_version: str | None = None
189+
redis_ok = False
190+
191+
if settings.cache_type.lower() == "redis" and settings.redis_url:
192+
try:
193+
redis = aioredis.Redis.from_url(settings.redis_url)
194+
await redis.ping()
195+
info = await redis.info()
196+
redis_version = info.get("redis_version")
197+
redis_ok = True
198+
except Exception as exc: # noqa: BLE001 - surface error
199+
redis_version = str(exc)
200+
201+
data = _build_payload(redis_version, redis_ok)
202+
203+
wants_html = format == "html" or "text/html" in request.headers.get("accept", "")
204+
if wants_html:
205+
return HTMLResponse(content=_render_html(data))
206+
return JSONResponse(content=data)

0 commit comments

Comments
 (0)