Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ The MCP Selenium Grid provides a MCP Server for creating and managing browser in

### 📖 Usage

The MCP Selenium Grid provides a Web API for creating and managing browser instances. The server runs on `localhost:8000` and exposes MCP endpoints at `/mcp`.
The MCP Selenium Grid provides a Web API for creating and managing browser instances. The server runs on `localhost:8000` and exposes MCP endpoints at `/mcp` (Http Transport) and `/sse` (Server Sent Events).
> Note: All requests to the server root `http://localhost:8000` will be redirected to either `/mcp` or `/sse` endpoints, depending on the request.

### MCP Client Configuration

Expand Down
3 changes: 1 addition & 2 deletions config.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
project_name: MCP Selenium Server
version: 0.1.0
project_name: MCP Selenium Grid
deployment_mode: docker # one of: docker, kubernetes (DeploymentMode enum values)
api_v1_str: /api/v1
api_token: CHANGE_ME
Expand Down
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@

[project]
name = "mcp-selenium-grid"
version = "0.1.0.dev2"
description = "MCP Server for managing Selenium Grid instances"
version = "0.1.0.dev3"
description = "MCP Server for managing Selenium Grid"
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.12"
Expand All @@ -25,7 +25,7 @@ dependencies = [
"fastapi[standard]>=0.115.14", # Web framework
"fastapi-cli[standard-no-fastapi-cloud-cli]>=0.0.8",
"fastapi-mcp>=0.3.4", # MCP integration for FastAPI
"pydantic[email]>=2.11.7", # Data validation, email support
"pydantic>=2.11.7", # Data validation
"pydantic-settings>=2.10.1", # Settings management (latest is 2.2.1)
"docker>=7.1.0", # Docker API client
"kubernetes>=33.1.0", # Kubernetes API client3
Expand Down Expand Up @@ -93,7 +93,7 @@ disallow_untyped_defs = true
check_untyped_defs = true

[[tool.mypy.overrides]]
module = ["fastapi_mcp"]
module = ["fastapi_mcp.*"]
ignore_missing_imports = true

[tool.pytest.ini_options]
Expand Down
25 changes: 25 additions & 0 deletions src/app/common/fastapi_mcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from fastapi.requests import Request
from fastapi.responses import Response
from fastapi_mcp.transport.http import FastApiHttpSessionManager

from .logger import logger


async def handle_fastapi_request(
name: str,
request: Request,
target_path: str,
method: str,
session_manager: FastApiHttpSessionManager,
) -> Response:
scope = dict(request.scope)
scope["path"] = target_path
scope["raw_path"] = target_path.encode("utf-8")
scope["query_string"] = request.scope.get("query_string", b"")
scope["method"] = method

new_request = Request(scope, request.receive)

logger.debug(f"Proxying internally to {name} at {target_path}")
response: Response = await session_manager.handle_fastapi_request(new_request)
return response
19 changes: 19 additions & 0 deletions src/app/common/logger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import logging
from os import getenv

from rich.logging import RichHandler

LOG_LEVEL = getenv("LOG_LEVEL", "INFO")

# Create
logger = logging.getLogger(f"MCP Selenium Grid:{__name__}")
logger.setLevel(LOG_LEVEL)

rich_handler = RichHandler(
level=LOG_LEVEL,
markup=True,
rich_tracebacks=True,
tracebacks_show_locals=True,
)

logger.addHandler(rich_handler)
43 changes: 43 additions & 0 deletions src/app/common/toml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from os import getcwd
from pathlib import Path
from tomllib import load
from typing import Any

ROOT_DIR = Path(getcwd()).resolve()


def load_value_from_toml(
keys: list[str], file_path: Path = ROOT_DIR / "pyproject.toml", default: Any = None
) -> Any:
"""
Load a nested value from a TOML file.

Args:
keys: List of nested keys to traverse.
file_path: Path to the TOML file.
default: Value to return if keys not found.

Returns:
The value from the TOML file.

Raises:
FileNotFoundError: If the file doesn't exist and no default is provided.
ValueError: If the keys are missing and no default is provided.
"""
if not file_path.exists():
if default is not None:
return default
raise FileNotFoundError(f"{file_path} not found")

try:
with file_path.open("rb") as f:
data = load(f)
for key in keys:
data = data[key]
return data
except KeyError:
if default is not None:
return default
raise ValueError(f"Keys {'.'.join(keys)} not found in {file_path}")
except Exception as e:
raise ValueError(f"Error reading {file_path}: {e}") from e
17 changes: 12 additions & 5 deletions src/app/core/settings.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
"""Core settings for MCP Server."""

from pydantic import Field, SecretStr
from pydantic import Field, SecretStr, field_validator

from app.common.toml import load_value_from_toml
from app.services.selenium_hub.models.general_settings import SeleniumHubGeneralSettings


class Settings(SeleniumHubGeneralSettings):
"""MCP Server settings."""

# API Settings
PROJECT_NAME: str = Field(default="MCP Selenium Server")
VERSION: str = Field(default="0.1.0")
API_V1_STR: str = Field(default="/api/v1")
PROJECT_NAME: str = "MCP Selenium Grid"
VERSION: str = ""

@field_validator("VERSION", mode="before")
@classmethod
def load_version_from_pyproject(cls, v: str) -> str:
return v or load_value_from_toml(["project", "version"])

API_V1_STR: str = "/api/v1"

# API Token
API_TOKEN: SecretStr = Field(default=SecretStr("CHANGE_ME"))
API_TOKEN: SecretStr = SecretStr("CHANGE_ME")

# Security Settings
BACKEND_CORS_ORIGINS: list[str] = Field(
Expand Down
105 changes: 86 additions & 19 deletions src/app/main.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,36 @@
"""MCP Server for managing Selenium Grid instances."""
"""MCP Server for managing Selenium Grid."""

import asyncio
from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator
from urllib.parse import urljoin

from fastapi import Depends, FastAPI, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response
from fastapi.security import HTTPAuthorizationCredentials
from fastapi_mcp import FastApiMCP
from fastapi_mcp import AuthConfig, FastApiMCP
from prometheus_client import generate_latest
from prometheus_fastapi_instrumentator import Instrumentator
from starlette.responses import Response

from app.common.fastapi_mcp import handle_fastapi_request
from app.common.logger import logger
from app.common.toml import load_value_from_toml
from app.dependencies import get_settings, verify_token
from app.models import HealthCheckResponse, HealthStatus, HubStatusResponse
from app.routers.browsers import router as browsers_router
from app.routers.selenium_proxy import router as selenium_proxy_router
from app.services.selenium_hub import SeleniumHub

DESCRIPTION = load_value_from_toml(["project", "description"])
SETTINGS = get_settings()
MCP_HTTP_PATH = "/mcp"
MCP_SSE_PATH = "/sse"


def create_application() -> FastAPI:
"""Create FastAPI application for MCP."""
# Initialize settings once at the start
settings = get_settings()

@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, Any]:
Expand All @@ -31,7 +39,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, Any]:
app.state.browsers_instances_lock = asyncio.Lock()

# Initialize Selenium Hub singleton
hub = SeleniumHub(settings) # This will create or return the singleton instance
hub = SeleniumHub(SETTINGS) # This will create or return the singleton instance

# Ensure hub is running and healthy before starting the application
try:
Expand All @@ -52,32 +60,32 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, Any]:
yield

# --- Server shutdown: remove Selenium Hub resources (Docker or Kubernetes) ---
# manager = SeleniumHubManager(settings)
# manager.cleanup()
hub.cleanup()

app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.VERSION,
description="MCP Server for managing Selenium Grid instances",
title=SETTINGS.PROJECT_NAME,
version=SETTINGS.VERSION,
description=DESCRIPTION,
lifespan=lifespan,
)

Instrumentator().instrument(app)

# CORS middleware
if settings.BACKEND_CORS_ORIGINS:
if SETTINGS.BACKEND_CORS_ORIGINS:
app.add_middleware(
CORSMiddleware,
allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
allow_origins=[str(origin) for origin in SETTINGS.BACKEND_CORS_ORIGINS],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

# Prometheus metrics endpoint
@app.get("/metrics")
def metrics(credentials: HTTPAuthorizationCredentials = Depends(verify_token)) -> Response:
async def metrics(
credentials: HTTPAuthorizationCredentials = Depends(verify_token),
) -> Response:
return Response(generate_latest(), media_type="text/plain")

# Health check endpoint
Expand All @@ -90,7 +98,7 @@ async def health_check(
is_healthy = await hub.check_hub_health()
return HealthCheckResponse(
status=HealthStatus.HEALTHY if is_healthy else HealthStatus.UNHEALTHY,
deployment_mode=settings.DEPLOYMENT_MODE,
deployment_mode=SETTINGS.DEPLOYMENT_MODE,
)

# Stats endpoint
Expand All @@ -116,19 +124,78 @@ async def get_hub_stats(
return HubStatusResponse(
hub_running=is_running,
hub_healthy=is_healthy,
deployment_mode=settings.DEPLOYMENT_MODE,
max_instances=settings.selenium_grid.MAX_BROWSER_INSTANCES,
deployment_mode=SETTINGS.DEPLOYMENT_MODE,
max_instances=SETTINGS.selenium_grid.MAX_BROWSER_INSTANCES,
browsers=browsers,
)

# Include browser management endpoints
app.include_router(browsers_router, prefix=settings.API_V1_STR)
app.include_router(browsers_router, prefix=SETTINGS.API_V1_STR)
# Include Selenium Hub proxy endpoints
app.include_router(selenium_proxy_router)

# --- MCP Integration ---
mcp = FastApiMCP(app)
mcp.mount() # Mounts at /mcp by default
mcp = FastApiMCP(
app,
name="MCP Selenium Grid",
description=DESCRIPTION,
describe_full_response_schema=True,
describe_all_responses=True,
auth_config=AuthConfig(
dependencies=[Depends(verify_token)],
),
)
mcp.mount_http(mount_path=MCP_HTTP_PATH)
mcp.mount_sse(mount_path=MCP_SSE_PATH)

@app.api_route("/", methods=["GET", "POST"])
async def root_proxy(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(verify_token),
) -> Response:
"""
FastApiMCP does not allow mounting directly on the root path `/`.
However, MCP clients (especially when using uvx) expect to connect on `/`.
This proxy handles requests on `/` and internally routes them to the proper MCP endpoints.
For SSE (Server-Sent Events) and HTTP transports, it redirects or proxies requests accordingly,
ensuring compatibility with client expectations without violating FastApiMCP mounting rules.
"""

accept = request.headers.get("accept", "").lower()
method = request.method.upper()
session_manager = mcp._http_transport # noqa: SLF001

if "text/event-stream" in accept:
if method == "GET":
return await handle_fastapi_request(
name="SSE",
request=request,
target_path=MCP_SSE_PATH,
method=method,
session_manager=session_manager,
)
elif method == "POST":
return await handle_fastapi_request(
name="SSE messages",
request=request,
target_path=urljoin(MCP_SSE_PATH, "/messages"),
method=method,
session_manager=session_manager,
)
else:
return JSONResponse({"detail": "Unsupported method"}, status_code=405)
elif "application/json" in accept:
return await handle_fastapi_request(
name="HTTP",
request=request,
target_path=MCP_HTTP_PATH,
method=method,
session_manager=session_manager,
)
else:
logger.warning(f"Unsupported Accept header or method: method={method}, accept={accept}")
return JSONResponse({"detail": "Unsupported Accept header or method"}, status_code=405)

# ----------------------

return app
Expand Down
5 changes: 2 additions & 3 deletions src/app/routers/browsers/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials

from app.common.logger import logger
from app.core.settings import Settings
from app.dependencies import get_settings, verify_token
from app.services.selenium_hub import SeleniumHub
Expand Down Expand Up @@ -66,9 +67,7 @@ async def create_browsers(
app_state.browsers_instances[browser.id] = browser
except Exception as e:
# Log the error and current browser configs for diagnostics
import logging # noqa: PLC0415

logging.error(
logger.error(
f"Exception in create_browsers: {e}. BROWSER_CONFIGS: {settings.selenium_grid.BROWSER_CONFIGS}"
)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
Expand Down
4 changes: 1 addition & 3 deletions src/app/routers/selenium_proxy.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Proxy router to securely expose Selenium Hub via FastAPI, supporting both Docker and Kubernetes deployments. All routes require HTTP Basic Auth matching the Selenium Hub configuration."""

import base64
import logging
from typing import Annotated
from urllib.parse import urljoin

Expand All @@ -10,6 +9,7 @@
from fastapi.responses import RedirectResponse
from fastapi.security import HTTPBasicCredentials

from app.common.logger import logger
from app.core.settings import Settings
from app.dependencies import get_settings, verify_basic_auth
from app.services.selenium_hub import SeleniumHub
Expand All @@ -34,9 +34,7 @@
"cache-control",
}


router = APIRouter(prefix=SELENIUM_HUB_PREFIX, tags=["Selenium Hub"])
logger = logging.getLogger(__name__)


# --- Utility Functions ---
Expand Down
Loading
Loading