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
91 changes: 88 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ For Docker-based deployment, ensure Docker is running and use the Docker configu
"--port", "8000"
],
"env": {
"API_TOKEN": "CHANGE_ME",
"API_TOKEN": "SERVER_AUTH_DISABLED",
"ALLOWED_ORIGINS": "[\"http://127.0.0.1:8000\"]",
"DEPLOYMENT_MODE": "docker",
"SELENIUM_GRID__USERNAME": "USER",
Expand Down Expand Up @@ -136,7 +136,7 @@ uvx mcp-selenium-grid helm uninstall --delete-namespace
"--port", "8000"
],
"env": {
"API_TOKEN": "CHANGE_ME",
"API_TOKEN": "SERVER_AUTH_DISABLED",
"ALLOWED_ORIGINS": "[\"http://127.0.0.1:8000\"]",
"DEPLOYMENT_MODE": "kubernetes",
"SELENIUM_GRID__USERNAME": "USER",
Expand Down Expand Up @@ -166,7 +166,7 @@ uvx mcp-selenium-grid helm uninstall --delete-namespace
"--rm",
"--init",
"-p", "8000:80",
"-e", "API_TOKEN=CHANGE_ME",
"-e", "API_TOKEN=SERVER_AUTH_DISABLED",
"-e", "ALLOWED_ORIGINS=[\"http://127.0.0.1:8000\"]",
"-e", "DEPLOYMENT_MODE=kubernetes", // required for docker
"-e", "SELENIUM_GRID__USERNAME=USER",
Expand All @@ -189,6 +189,91 @@ uvx mcp-selenium-grid helm uninstall --delete-namespace

> The server will be available at `http://localhost:8000` with interactive API documentation at `http://localhost:8000/docs`.

### Server with auth enabled

#### UVX

Using default args

```bash
uvx mcp-selenium-grid server run
```

Custom args

```bash
API_TOKEN=CHANGE_ME uvx mcp-selenium-grid server run --host 127.0.0.1 --port 8000
```

#### Docker

Default args

```bash
docker run -i --rm --init -p 8000:80 ghcr.io/falamarcao/mcp-selenium-grid:latest
```

Custom args

```bash
docker run -i --rm --init -p 8000:80 \
-e API_TOKEN=CHANGE_ME \
-e ALLOWED_ORIGINS='["http://127.0.0.1:8000"]' \
-e DEPLOYMENT_MODE=kubernetes \
-e SELENIUM_GRID__USERNAME=USER \
-e SELENIUM_GRID__PASSWORD=CHANGE_ME \
-e SELENIUM_GRID__VNC_PASSWORD=CHANGE_ME \
-e SELENIUM_GRID__VNC_VIEW_ONLY=false \
-e SELENIUM_GRID__MAX_BROWSER_INSTANCES=4 \
-e SELENIUM_GRID__SE_NODE_MAX_SESSIONS=1 \
-e KUBERNETES__KUBECONFIG=/kube/config-local-k3s \
-e KUBERNETES__CONTEXT=k3s-selenium-grid \
-e KUBERNETES__NAMESPACE=selenium-grid-dev \
-e KUBERNETES__SELENIUM_GRID_SERVICE_NAME=selenium-grid \
ghcr.io/falamarcao/mcp-selenium-grid:latest
```

#### MCP Server configuration (mcp.json)

```json
{
"mcpServers": {
"mcp-selenium-grid": {
"url": "http://localhost:8000",
"headers": {
"Authorization": "Bearer CHANGE_ME"
}
}
}
}
```

```json
{
"mcpServers": {
"mcp-selenium-grid": {
"url": "http://localhost:8000/mcp",
"headers": {
"Authorization": "Bearer CHANGE_ME"
}
}
}
}
```

```json
{
"mcpServers": {
"mcp-selenium-grid": {
"url": "http://localhost:8000/sse",
"headers": {
"Authorization": "Bearer CHANGE_ME"
}
}
}
}
```

## 🤝 Contributing

For development setup, testing, and contribution guidelines, please see [CONTRIBUTING.md](CONTRIBUTING.md).
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

[project]
name = "mcp-selenium-grid"
version = "0.1.0.dev6"
version = "0.1.0.dev7"
description = "MCP Server for managing Selenium Grid"
readme = "README.md"
license = { file = "LICENSE" }
Expand Down
17 changes: 16 additions & 1 deletion src/app/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
HTTPBearer,
)

from app.common.logger import logger
from app.core.settings import Settings


Expand All @@ -21,7 +22,7 @@ def get_settings() -> Settings:


# HTTP Bearer token setup
security = HTTPBearer()
security = HTTPBearer(auto_error=False)
basic_auth_scheme = HTTPBasic(auto_error=True)


Expand All @@ -46,6 +47,20 @@ async def verify_token(
HTTPException: 401 if the token is invalid or missing.
"""

# If API_TOKEN is empty, skip auth (allow access)
if settings.API_TOKEN.get_secret_value() == "SERVER_AUTH_DISABLED":
logger.critical(
"API_TOKEN is disabled — skipping token verification, access granted as anonymous".upper()
)
return {"sub": "anonymous"}

# Check if header exists (auto_error=False)
if not credentials:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authenticated",
)

if not compare_digest(settings.API_TOKEN.get_secret_value(), credentials.credentials):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
Expand Down
26 changes: 13 additions & 13 deletions src/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from app.routers.selenium_proxy import router as selenium_proxy_router
from app.services.selenium_hub import SeleniumHub

SETTINGS = get_settings()
settings = get_settings()
MCP_HTTP_PATH = "/mcp"
MCP_SSE_PATH = "/sse"

Expand All @@ -37,7 +37,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 @@ -61,19 +61,19 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, Any]:
hub.cleanup()

app = FastAPI(
title=SETTINGS.PROJECT_NAME,
version=SETTINGS.VERSION,
description=SETTINGS.DESCRIPTION,
title=settings.PROJECT_NAME,
version=settings.VERSION,
description=settings.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=["*"],
Expand All @@ -96,7 +96,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 @@ -120,22 +120,22 @@ 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=app_state.browsers_instances,
webdriver_remote_url=hub.WEBDRIVER_REMOTE_URL,
)

# 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,
name=SETTINGS.PROJECT_NAME,
description=SETTINGS.DESCRIPTION,
name=settings.PROJECT_NAME,
description=settings.DESCRIPTION,
describe_full_response_schema=True,
describe_all_responses=True,
auth_config=AuthConfig(
Expand Down
26 changes: 25 additions & 1 deletion src/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,31 @@ def client(request: FixtureRequest) -> Generator[TestClient, None, None]:

with TestClient(app) as test_client:
yield test_client
app.dependency_overrides = {}

app.dependency_overrides.clear()


# Client fixture with API_TOKEN patched to empty string
@pytest.fixture(scope="function", params=[DeploymentMode.DOCKER, DeploymentMode.KUBERNETES])
def client_disabled_auth(request: FixtureRequest) -> Generator[TestClient, None, None]:
from app.main import create_application # noqa: PLC0415
from fastapi.testclient import TestClient # noqa: PLC0415

app = create_application()

# Override settings based on deployment mode
settings = get_settings()
settings.DEPLOYMENT_MODE = request.param

# Disable Auth
settings.API_TOKEN = SecretStr("SERVER_AUTH_DISABLED")

app.dependency_overrides[get_settings] = lambda: settings

with TestClient(app) as test_client:
yield test_client

app.dependency_overrides.clear()


def reset_selenium_hub_singleton() -> None:
Expand Down
9 changes: 8 additions & 1 deletion src/tests/integration/test_health_and_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from app.models import HealthStatus
from fastapi import status
from fastapi.testclient import TestClient
from pytest import FixtureRequest
from pytest import FixtureRequest, MonkeyPatch


@pytest.mark.integration
Expand Down Expand Up @@ -34,6 +34,13 @@ def test_health_check_requires_auth(client: TestClient) -> None:
assert response.status_code == status.HTTP_403_FORBIDDEN


@pytest.mark.integration
def test_disabled_auth(client_disabled_auth: TestClient, monkeypatch: MonkeyPatch) -> None:
"""Test health check endpoint without authentication when API_TOKEN is empty."""
response = client_disabled_auth.get("/health")
assert response.status_code == status.HTTP_200_OK


@pytest.mark.integration
def test_hub_stats_endpoint(
client: TestClient, auth_headers: dict[str, str], request: FixtureRequest
Expand Down
7 changes: 7 additions & 0 deletions src/tests/unit/test_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ def get_settings_override_missing() -> Settings:
async def verify_token_override(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict[str, str]:
# Check if header exists (auto_error=False)
if not credentials:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authenticated",
)

if credentials.credentials != "valid_token":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
Expand Down
8 changes: 8 additions & 0 deletions src/tests/unit/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ def test_deployment_mode_override_by_env(monkeypatch: pytest.MonkeyPatch) -> Non
monkeypatch.delenv("DEPLOYMENT_MODE", raising=False)


@pytest.mark.unit
def test_api_token_override_by_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("API_TOKEN", "")
settings = Settings()
assert settings.API_TOKEN.get_secret_value() == ""
monkeypatch.delenv("API_TOKEN", raising=False)


# --- YAML Loading and Special Behaviors ---
@pytest.mark.unit
def test_settings_loads_from_yaml(tmp_path: Path) -> None:
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading