Merge pull request #2775 from yueguobin/fix/mcp-initialization-ready-check

Feature: add MCP server initialization check with ready state tracking
This commit is contained in:
Jeremy Grossmann 2026-06-06 16:56:18 +02:00 committed by GitHub
commit 93293a327d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 166 additions and 0 deletions

View File

@ -44,6 +44,7 @@ from mcp.server.transport_security import TransportSecuritySettings
from gns3server.config import Config
from gns3server.services import auth_service
from gns3server.utils.request_utils import extract_client_info
from .projects import (
list_projects_handler, get_project_handler, create_project_handler,
delete_project_handler, open_project_handler, close_project_handler,
@ -70,6 +71,59 @@ from .computes import (
log = logging.getLogger(__name__)
# ── Server ready state ────────────────────────────────────────────────
# Tracks whether GNS3 server has completed initialization.
# MCP connections wait up to 5 seconds for startup to complete, then return
# 503 Service Unavailable if initialization is not complete to prevent
# "Received request before initialization was complete" errors.
_mcp_ready_event = asyncio.Event()
def set_mcp_server_ready(ready: bool = True) -> None:
"""
Set MCP server ready state.
Should be called after GNS3 startup completes (database, controller, etc.)
to allow MCP connections to proceed.
Args:
ready: True to mark server as ready, False to mark as not ready
"""
if ready:
_mcp_ready_event.set()
log.info("MCP server is now ready to accept connections")
else:
_mcp_ready_event.clear()
async def wait_for_mcp_ready() -> bool:
"""
Wait until MCP server is ready before accepting connections.
Returns:
True if server is ready, False if timeout reached
Returns immediately if already ready. Otherwise waits with a timeout
and returns False if server does not become ready in time.
"""
if _mcp_ready_event.is_set():
return True
log.debug("MCP server not ready yet, waiting for initialization to complete...")
try:
await asyncio.wait_for(_mcp_ready_event.wait(), timeout=5.0)
log.debug("MCP server is now ready, proceeding with connection")
return True
except asyncio.TimeoutError:
log.warning(
"MCP server ready check timed out after 5 seconds - "
"GNS3 server initialization may have issues"
)
return False
# ── Perconnection JWT token ─────────────────────────────────────────
# Set during SSE authentication, read by tool handlers running in the
# same asyncio task (contextvars propagate through asyncio.to_thread).
@ -459,6 +513,22 @@ def _make_auth_wrapper(inner_app):
"""
async def auth_wrapper(scope, receive, send):
# Wait for GNS3 server to complete initialization before accepting MCP connections
server_ready = await wait_for_mcp_ready()
if not server_ready:
# Server initialization timed out - return 503 Service Unavailable
client_info = extract_client_info(scope, auth_service)
log.warning(
f"Rejecting MCP connection - GNS3 server initialization not complete. "
f"Client: {client_info['host']}:{client_info['port']} ({client_info['user_info']}, Path: {client_info['path']})"
)
response = Response(
"GNS3 server initialization not complete - please retry later",
status_code=503
)
await response(scope, receive, send)
return
if scope["type"] == "http" and scope["method"] == "GET":
token = None
headers = dict(scope.get("headers", []))

View File

@ -84,6 +84,11 @@ async def startup(app: FastAPI) -> None:
m = module.instance()
m.port_manager = PortManager.instance()
# Mark MCP server as ready to accept connections
from gns3server.api.routes.mcp import set_mcp_server_ready
set_mcp_server_ready(True)
log.info("GNS3 server startup completed")
async def shutdown(app: FastAPI) -> None:
"""

View File

@ -0,0 +1,91 @@
"""
Utilities for extracting request information from ASGI scope.
This module provides reusable functions for extracting client information
from ASGI scope dictionaries for logging and debugging purposes.
"""
import logging
from urllib.parse import parse_qs
from typing import Dict, Any, Optional
log = logging.getLogger(__name__)
def extract_client_info(scope: Dict[str, Any], auth_service_instance: Optional[Any] = None) -> Dict[str, str]:
"""
Extract client information from ASGI scope for logging purposes.
Args:
scope: ASGI scope dictionary containing request metadata
auth_service_instance: Optional auth service instance for token validation
Returns:
Dictionary with client information:
- host: Client IP address
- port: Client port
- path: Request path
- method: Request method
- username: Authenticated username (if token provided and valid)
- user_info: Human-readable user info string
"""
# Extract client address and port
client = scope.get("client", (None, None))
client_host = client[0] if client and client[0] else "unknown"
client_port = str(client[1]) if client and len(client) > 1 else "unknown"
# Extract request info
path = scope.get("path", "unknown")
method = scope.get("method", "unknown")
# Try to extract username from token
username = None
if auth_service_instance:
try:
headers = dict(scope.get("headers", []))
auth = headers.get(b"authorization", b"").decode()
token = None
# Try Authorization header
if auth.startswith("Bearer "):
token = auth[7:]
# Try query parameter
if not token:
params = parse_qs(scope.get("query_string", b"").decode())
tokens = params.get("token", [])
if tokens:
token = tokens[0]
# Validate and extract username
if token:
username = auth_service_instance.get_username_from_token(token)
except Exception as e:
log.debug(f"Failed to extract username from token: {e}")
username = None
# Create user-friendly info string
user_info = f"user '{username}'" if username else "unauthenticated user"
return {
"host": client_host,
"port": client_port,
"path": path,
"method": method,
"username": username,
"user_info": user_info
}
def format_client_log(client_info: Dict[str, str], message: str) -> str:
"""
Format a log message with client information.
Args:
client_info: Client information dict from extract_client_info()
message: Log message
Returns:
Formatted log string with client prefix
"""
return f"{message} - Client: {client_info['host']}:{client_info['port']} ({client_info['user_info']}, Path: {client_info['path']})"