Skip to main content
Glama
README.md
# Playwright MCP Server

> A production-ready [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that exposes full browser automation over HTTP — built with the [MCP Python SDK v2](https://github.com/modelcontextprotocol/python-sdk) (`MCPServer`) and [Playwright](https://playwright.dev/python/), deployable to Azure Container Apps. Supports multiple concurrent clients via isolated sessions — all output returned inline (base64 for images), nothing written to disk.

![Python](https://img.shields.io/badge/Python-3.11%2B-blue)
![MCP SDK](https://img.shields.io/badge/MCP_SDK-v2-green)
![Playwright](https://img.shields.io/badge/Playwright-Chromium-orange)
![Azure](https://img.shields.io/badge/Deploy-Azure%20Container%20Apps-0078D4)

Control a real Chromium browser through simple HTTP calls. No Selenium, no WebDriver setup, no manual MCP protocol boilerplate. Each client gets an isolated browser session (separate cookies, localStorage, history) so multiple agents can operate in parallel. All output is returned inline — screenshots as base64, DOM as HTML — nothing is written to disk. Requests are authenticated via an `x-api-key` header, with the secret loaded from a `.env` file.

## When to Use This

| Use Case | Example |
|----------|---------|
| **AI agent browser control** | Let an LLM agent navigate, click, and scrape web pages via MCP tool calls |
| **End-to-end test automation** | Drive a real Chromium browser over HTTP from any language or test runner |
| **Web scraping pipelines** | Extract DOM snapshots, evaluate JS, and capture screenshots programmatically |
| **RPA (Robotic Process Automation)** | Automate repetitive web workflows — form filling, file uploads, multi-tab flows |
| **Automated screenshots / visual monitoring** | Capture full-page screenshots of any URL on demand |
| **Remote browser execution** | Run browser automation on a server or in Azure without a local display |

### When NOT to Use This

- You only need static HTML parsing — use `requests` + `BeautifulSoup` instead
- You need parallel browsers at scale — consider Playwright's own grid or Playwright Test
- You need a full GUI test framework with assertions and reports — use Playwright Test or Pytest-Playwright directly

## Architecture

### Project Files

| File | Purpose |
|------|---------|
| `server.py` | Main MCP server — registers all browser tools, applies API-key auth middleware, and starts MCPServer via uvicorn |
| `validate_server.py` | Pre-flight checker — verifies all imports and runtime dependencies |
| `requirements.txt` | Python dependency list |
| `.env` | Environment variables (API key, etc.) — **not committed to git** |

### How It Works

- `server.py` creates an `MCPServer` instance and builds the ASGI app with `mcp.streamable_http_app(stateless_http=True)`
- An `APIKeyMiddleware` (Starlette) is added to the ASGI app — every request must include a valid `x-api-key` header
- The API key is loaded from a `.env` file via `python-dotenv` at startup (`MCP_API_KEY` variable)
- **Session-based**: each client calls `session_create` to get an isolated browser context (separate cookies, localStorage, page). Multiple sessions run in parallel on a shared Chromium instance
- Playwright is **lazily initialized** — the Chromium browser only starts on the first session creation
- **No disk I/O** — screenshots are returned as base64, DOM snapshots as HTML strings. Nothing is written to the filesystem
- Idle sessions are automatically evicted after `SESSION_IDLE_TIMEOUT_S` (default 10 minutes). The server enforces `MAX_SESSIONS` (default 10)
- A `health_check` tool acts as a lightweight health probe — it responds instantly without starting the browser
- The server binds to `0.0.0.0` and reads the `PORT` environment variable (default `8000`)

## Architecture Diagram

```mermaid
flowchart LR
    ClientA[Client A]
    ClientB[Client B]
    subgraph Server[Playwright MCP Server]
      direction TB
      Auth[APIKeyMiddleware]
      MCPServer[MCPServer]
      Sessions[Session Manager]
      Browser[Shared Chromium Instance]
      CtxA[Context A / Page A]
      CtxB[Context B / Page B]
    end
    ClientA -->|"HTTP + x-api-key"| Auth
    ClientB -->|"HTTP + x-api-key"| Auth
    Auth -->|valid key| MCPServer
    Auth -->|invalid key| ClientA
    MCPServer -->|calls tool| Sessions
    Sessions -->|session A| CtxA
    Sessions -->|session B| CtxB
    CtxA -->|isolated browsing| Browser
    CtxB -->|isolated browsing| Browser
    Sessions -->|"JSON + base64"| MCPServer
    MCPServer -->|HTTP response| ClientA
    MCPServer -->|HTTP response| ClientB
```

> **Step-by-step:**
> 1. Client sends an HTTP POST to `https://<host>/mcp` with an `x-api-key` header
> 2. `APIKeyMiddleware` validates the key — returns `401` if invalid or missing
> 3. Client calls `session_create` to get an isolated `session_id`
> 4. All subsequent tool calls include `session_id` to target the correct browser context
> 5. The shared Chromium instance is lazily started on the first session creation
> 6. Results (HTML, base64 images, JSON) are returned inline — nothing written to disk
> 7. Client calls `session_close` when done; idle sessions are auto-evicted

## Features

| | |
|---|---|
| **Multi-client sessions** | Each client gets an isolated browser context (cookies, storage, page) via `session_create`. Multiple agents work in parallel |
| **No disk I/O** | Screenshots returned as base64, DOM as HTML. Nothing written to the filesystem — safe for serverless/container deployments |
| **API-key authentication** | All requests require a valid `x-api-key` header — key loaded from `.env`, compared with `hmac.compare_digest` |
| **Auto-cleanup** | Idle sessions evicted after configurable timeout; max session limit enforced with LRU eviction |
| **Lazy-loaded browser** | Shared Chromium instance starts on first session creation — zero startup overhead |
| **Full browser automation** | Navigate, click, type, screenshot, evaluate JS, DOM snapshot, resize, and key press |
| **Azure Container Apps ready** | Built-in `health_check` tool satisfies health probes; deploy with `scripts/deploy-aca.ps1` |
| **Async / await** | Every tool is a native Python async function — no blocking calls |
| **Streamable HTTP transport** | MCPServer uses streamable-http for real-time MCP communication |
| **Distributed tracing (OTel)** | Built-in OpenTelemetry instrumentation with W3C `traceparent` propagation. Traces flow from calling services through MCP tool calls to Splunk APM / Jaeger / any OTLP backend |

## Tools Available

All browser tools accept an optional `session_id` parameter. If omitted, a new session is auto-created.

### Session Management

| Tool | Description | Input |
|------|-------------|-------|
| `health_check()` | Server health, active session count | None |
| `session_create()` | Create an isolated browser session | None |
| `session_close()` | Close a session and free resources | `session_id` |
| `session_list()` | List all active sessions with idle times | None |

### Browser Automation

| Tool | Description | Input |
|------|-------------|-------|
| `browser_navigate()` | Navigate to a URL (http, https, or data: URI) | `session_id?`, `url` |
| `browser_click()` | Click an element by selector | `session_id?`, `ref` |
| `browser_type()` | Type text into an input field | `session_id?`, `ref`, `text` |
| `browser_press_key()` | Press a keyboard key | `session_id?`, `key` |
| `browser_take_screenshot()` | Capture page as base64 PNG | `session_id?`, `fullPage?` |
| `browser_pdf_save()` | Generate a PDF of the current page | `session_id?`, `format?`, `landscape?`, `print_background?`, `scale?`, `upload_to_storage?`, `html?` |
| `browser_snapshot()` | Get full HTML DOM content | `session_id?` |
| `browser_evaluate()` | Execute JavaScript in page context | `session_id?`, `function` |
| `browser_resize()` | Resize the browser viewport | `session_id?`, `width`, `height` |

## Prerequisites

- Python 3.8+
- pip or conda

## Installation

1. **Clone or download this project**

2. **Create a virtual environment**
   ```bash
   python -m venv .venv
   ```

3. **Activate the virtual environment**
   - **Windows:**
     ```bash
     .venv\Scripts\activate
     ```
   - **Linux/macOS:**
     ```bash
     source .venv/bin/activate
     ```

4. **Install dependencies**
   ```bash
   pip install -r requirements.txt
   ```

5. **Configure environment variables**

   Copy the template and fill in your API key:
   ```bash
   # Linux/macOS
   cp .env.example .env

   # Windows
   copy .env.example .env
   ```

   Then edit `.env` and set at minimum:
   ```bash
   MCP_API_KEY=your-secure-random-key-here
   ```

   See [`.env.example`](.env.example) for all available settings (timeouts, session limits, Azure config, etc.).

   > ⚠️ The `.env` file is git-ignored by default. **Never commit your API key to source control.**

## Running Locally

```bash
python server.py
```

The server will start on `http://0.0.0.0:8000` by default. All requests must include the `x-api-key` header matching the `MCP_API_KEY` value from your `.env` file.

### Custom Port

Set the `PORT` environment variable to use a different port:

```bash
# Windows
set PORT=3000
python server.py

# Linux/macOS
export PORT=3000
python server.py
```

## Connect from Zoo Code

To use this MCP server from [Zoo Code](https://marketplace.visualstudio.com/items?itemName=zoocodeorganization.zoo-code), add the following to your MCP settings file (`mcp_settings.json`):

```json
{
  "mcpServers": {
    "playwright_local": {
      "type": "streamable-http",
      "url": "http://localhost:8100/mcp",
      "headers": {
        "x-api-key": "YOUR_KEY_HERE"
      },
      "alwaysAllow": []
    }
  }
}
```

Replace `YOUR_KEY_HERE` with the `MCP_API_KEY` value from your `.env` file, and adjust the port if you changed `PORT` in `.env`.

> **Tip:** Make sure the Docker container is running before connecting. Use `.\scripts\start-docker.ps1` or start it manually with `docker run`.

## Quick Usage Example

With the server running, call any tool via the `/mcp` endpoint. **You must include the `x-api-key` header** with every request.

### 1. Create a session

```bash
curl -X POST http://localhost:8000/mcp \
  -H "Content-Type: application/json" \
  -H "x-api-key: your-secure-random-key-here" \
  -d '{
    "method": "tools/call",
    "params": { "name": "session_create", "arguments": {} }
  }'
```

Response:
```json
{ "session_id": "a1b2c3d4-...", "status": "created" }
```

### 2. Navigate (pass the session_id)

```bash
curl -X POST http://localhost:8000/mcp \
  -H "Content-Type: application/json" \
  -H "x-api-key: your-secure-random-key-here" \
  -d '{
    "method": "tools/call",
    "params": {
      "name": "browser_navigate",
      "arguments": { "session_id": "a1b2c3d4-...", "url": "https://example.com" }
    }
  }'
```

### 3. Take a screenshot (returned as base64)

```bash
curl -X POST http://localhost:8000/mcp \
  -H "Content-Type: application/json" \
  -H "x-api-key: your-secure-random-key-here" \
  -d '{
    "method": "tools/call",
    "params": {
      "name": "browser_take_screenshot",
      "arguments": { "session_id": "a1b2c3d4-...", "fullPage": true }
    }
  }'
```

Response:
```json
{ "base64_png": "iVBORw0KGgoAAAANSUhEUgAA...", "format": "png" }
```

### 4. Close the session when done

```bash
curl -X POST http://localhost:8000/mcp \
  -H "Content-Type: application/json" \
  -H "x-api-key: your-secure-random-key-here" \
  -d '{
    "method": "tools/call",
    "params": {
      "name": "session_close",
      "arguments": { "session_id": "a1b2c3d4-..." }
    }
  }'
```

> **Tip:** If you omit `session_id` from any browser tool, a new session is auto-created and its ID is returned in the response.

If the API key is missing or incorrect, you will receive:
```json
{ "error": "Unauthorized. Provide a valid x-api-key header." }
```
with HTTP status `401`.

## Test With Postman

You can also test MCP functionality using Postman and the running `http://localhost:8000/mcp` endpoint.

1. Open Postman.
2. Create a new MCP request or a new HTTP POST request if your version does not expose MCP request types.
3. Set the request URL to:

   ```text
   http://localhost:8000/mcp
   ```

4. **Add the authentication header:**
   - Go to the **Headers** tab.
   - Add a header with key `x-api-key` and value set to your `MCP_API_KEY` from the `.env` file.

5. **Create a session first**, then use the returned `session_id` in subsequent calls:

   ```json
   {
     "method": "tools/call",
     "params": {
       "name": "session_create",
       "arguments": {}
     }
   }
   ```

6. Use the `session_id` from the response to call browser tools:

   ```json
   {
     "method": "tools/call",
     "params": {
       "name": "browser_navigate",
       "arguments": {
         "session_id": "<session_id from step 5>",
         "url": "https://example.com"
       }
     }
   }
   ```

7. Click **Send** or **Run**.
8. Review the response panel to confirm the tool execution and any returned status.


## Deployment to Azure

### Quick Deploy — Azure Container Apps (Recommended)

The fastest path to a public HTTPS endpoint with Azure-managed TLS certificates. A single PowerShell script handles everything.

**Prerequisites:**
- Azure CLI installed and logged in (`az login`)
- Container Apps CLI extension: `az extension add --name containerapp --upgrade`
- Python 3.8+

**Steps:**

1. **Configure your `.env` file** with Azure settings:
   ```env
   AZURE_SUBSCRIPTION_ID=<your-subscription-id>
   AZURE_RESOURCE_GROUP=mcp-playwright-rg
   AZURE_LOCATION=westeurope
   AZURE_APP_NAME=mcp-playwright
   MCP_API_KEY=                              # leave empty to auto-generate
   ```

2. **Run the deploy script:**
   ```powershell
   .\scripts\deploy-aca.ps1
   ```

3. The script will:
   - Create a resource group
   - Build your Docker image and push it to a managed Azure Container Registry
   - Create an Azure Container App with public HTTPS ingress
   - Inject `MCP_API_KEY`, `HEADLESS`, and `PORT` as environment variables
   - Run a smoke test against the `health_check` tool
   - Print the full HTTPS endpoint and API key

4. **Test the endpoint:**
   ```bash
   curl -X POST https://<your-app>.<region>.azurecontainerapps.io/mcp \
     -H "Content-Type: application/json" \
     -H "x-api-key: <your-key>" \
     -d '{"method":"tools/call","params":{"name":"browser_navigate","arguments":{"url":"https://example.com"}}}'
   ```

5. **Tear down** when finished:
   ```powershell
   .\scripts\teardown-aca.ps1
   ```

> See [`plans/azure-quick-test-aca.md`](plans/azure-quick-test-aca.md) for the full architecture and alternative deployment options.

### Alternative: Azure App Service for Containers

1. **Create an App Service with a custom container**
   ```bash
   az appservice plan create --name <plan-name> --resource-group <rg-name> --sku B2 --is-linux
   az webapp create --resource-group <rg-name> --plan <plan-name> --name <app-name> --deployment-container-image-name <your-acr>.azurecr.io/playwright-mcp-server:latest
   ```

2. **Configure environment** (including the API key)
   ```bash
   az webapp config appsettings set --resource-group <rg-name> --name <app-name> \
     --settings PORT=8000 HEADLESS=true MCP_API_KEY=your-secure-random-key-here
   ```

3. **Configure the health probe** (Azure Portal → App Service → Health Check)
   - Set path to `/mcp`

### Local Docker

**Quick start (recommended):** Use the provided PowerShell script to build and run on port 3010 (avoids conflicts with the default 8000):

```powershell
.\scripts\start-docker.ps1
```

This will:
- Build the Docker image from the project `Dockerfile`
- Stop and remove any existing `playwright-mcp` container
- Start a new container mapping **host port 3010 → container port 8000**
- Read `MCP_API_KEY` from `.env` (or auto-generate one)
- Print the endpoint URL, API key, and test commands

The MCP endpoint will be available at `http://localhost:3010/mcp`.

**Options:**

```powershell
# Use a custom host port
.\scripts\start-docker.ps1 -Port 4000

# Force a no-cache rebuild
.\scripts\start-docker.ps1 -Rebuild

# Both
.\scripts\start-docker.ps1 -Port 4000 -Rebuild
```

**Manual build and run** (if you prefer not to use the script):

```bash
docker build -t playwright-mcp-server .
docker run -p 3010:8000 --env-file .env playwright-mcp-server
```

> **Note:** The container listens on port 8000 internally. The `-p 3010:8000` flag maps host port 3010 to the container's port 8000, avoiding conflicts if port 8000 is already in use on your machine.

## Environment Variables

| Variable | Default | Description |
|----------|---------|-------------|
| `MCP_API_KEY` | *(none — required)* | Secret key for `x-api-key` header authentication. The server will return `500` on all requests if this is not set. |
| `PORT` | `8000` | Port the server listens on |
| `HEADLESS` | `true` | Set to `false` to show the browser window (local dev only) |
| `NAVIGATION_TIMEOUT_MS` | `30000` | Maximum time (ms) to wait for page navigation (e.g. `page.goto`). Increase for slow sites. |
| `ACTION_TIMEOUT_MS` | `10000` | Maximum time (ms) to wait for element actions (click, fill, etc.). |
| `SESSION_IDLE_TIMEOUT_S` | `600` | Seconds a session can sit idle before automatic eviction (default 10 minutes). |
| `MAX_SESSIONS` | `10` | Maximum number of concurrent browser sessions. When the limit is reached, the oldest idle session is evicted to make room. |
| `UVICORN_WORKERS` | `1` | Number of uvicorn workers. Keep at `1` (Playwright state is in-process). Scale horizontally with container replicas. |
| `ENABLE_HTTPS` | `false` | Set to `true` to serve over HTTPS using a self-signed certificate (baked into the Docker image). |
| `SSL_CERTFILE` | `/app/certs/server.crt` | Path to TLS certificate file. Only used when `ENABLE_HTTPS=true`. |
| `SSL_KEYFILE` | `/app/certs/server.key` | Path to TLS private key file. Only used when `ENABLE_HTTPS=true`. |
| `DOCKER_HOST_PORT` | `3010` | Host port used by `scripts/start-docker.ps1` for the Docker port mapping. |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | *(empty)* | OTLP/HTTP endpoint for the OTel Collector (e.g. `http://localhost:4318`). When set, traces are exported via OTLP. |
| `OTEL_SERVICE_NAME` | `playwright-mcp` | Service name shown in trace backends (Splunk APM, Jaeger, etc.). |
| `OTEL_RESOURCE_ATTRIBUTES` | *(empty)* | Extra resource attributes as `key=value,key=value` (e.g. `deployment.environment=dev`). |

> All variables can be set in the `.env` file (loaded automatically via `python-dotenv`) or passed directly as environment variables.

## Scalability

This server runs **one shared Chromium instance per process** with multiple isolated sessions. To scale:

| Strategy | Details |
|----------|---------|
| **Session isolation** | Each client gets its own `BrowserContext` + `Page` — separate cookies, localStorage, and browsing history. Up to `MAX_SESSIONS` (default 10) run concurrently. |
| **Auto-eviction** | Idle sessions are evicted after `SESSION_IDLE_TIMEOUT_S`. When `MAX_SESSIONS` is reached, the oldest idle session is evicted to make room for new clients. |
| **Horizontal replicas** | Deploy multiple container instances behind a load balancer (Azure Container Apps, Kubernetes, App Service scale-out). Each replica manages its own browser and session pool. |
| **Keep workers = 1** | `UVICORN_WORKERS` must stay at `1` because Playwright state (browser, sessions) is in-process and not shared across workers. |
| **Graceful shutdown** | The server closes all sessions and the browser cleanly on SIGTERM (Docker stop, Azure restart) via `atexit` + signal handlers — preventing orphaned Chromium processes. |
| **Timeouts** | All browser actions enforce configurable timeouts (`NAVIGATION_TIMEOUT_MS`, `ACTION_TIMEOUT_MS`) so a hung page doesn't block the server indefinitely. |
| **No disk I/O** | All output (screenshots, DOM) is returned inline — no filesystem writes mean the server is safe for read-only container filesystems and serverless deployments. |
| **Docker layer caching** | The Dockerfile splits pip install and Playwright browser download into separate layers — code-only changes rebuild in seconds, not minutes. |

## Testing

### Validate Dependencies

```bash
python validate_server.py
```

### Run the Test Suite

```bash
python test_fastmcp_simple.py
```

### Test with MCP Inspector (Recommended)

[MCP Inspector](https://github.com/modelcontextprotocol/inspector) is the official interactive tool for exploring and testing MCP servers. It gives you a visual UI to browse all registered tools, call them with custom inputs, and inspect responses — no Postman or curl needed.

**Prerequisites:** Node.js installed

1. Start the server:
   ```bash
   python server.py
   ```

2. In a new terminal, launch MCP Inspector:
   ```bash
   npx @modelcontextprotocol/inspector
   ```

3. Open the Inspector UI (it will print a local URL, typically `http://localhost:5173`)

4. Connect to your running server:
   - Transport: **SSE** or **Streamable HTTP**
   - URL: `http://localhost:8000/mcp`

5. You will see all registered tools (`session_create`, `session_close`, `browser_navigate`, `browser_click`, `browser_take_screenshot`, etc.) listed in the left panel. Click any tool, fill in the inputs, and hit **Run Tool** to test it live.

> MCP Inspector is the fastest way to verify your server is working correctly before deploying to Azure.

## Project Structure

```
.
├── server.py                       # Main MCP server (API-key auth, browser tools, uvicorn)
├── .env                            # Environment variables — runtime + Azure config (git-ignored)
├── .env.example                    # Template with all available settings — copy to .env
├── requirements.txt                # Python dependencies
├── Dockerfile                      # Container image definition (non-root, layered)
├── .dockerignore                   # Files excluded from Docker build context
├── .gitignore                      # Files excluded from version control
├── test_fastmcp_simple.py          # Test suite
├── validate_server.py              # Dependency validation
├── scripts/
│   ├── start-docker.ps1            # Build & run locally in Docker
│   ├── deploy-aca.ps1              # One-command Azure Container Apps deployment
│   ├── teardown-aca.ps1            # Delete all Azure resources
│   └── generate_cert.py            # Generate self-signed TLS cert (used during Docker build)
├── plans/
│   └── azure-quick-test-aca.md     # Architecture plan for Azure deployment
├── README.md                       # This file
└── .venv/                          # Virtual environment (auto-generated)
```

## Troubleshooting

**All requests return `401 Unauthorized`:**
- Ensure you are sending the `x-api-key` header with the correct value
- Check that `MCP_API_KEY` is set in your `.env` file (or environment)

**All requests return `500` — "Server API key not configured":**
- The `MCP_API_KEY` environment variable is missing or empty
- Create or edit the `.env` file and add `MCP_API_KEY=your-key-here`

**Browser won't initialize:**
- Ensure Playwright chromium is installed: `playwright install chromium`
- Check system dependencies are installed (Linux/macOS)

**Port already in use:**
- Change the PORT environment variable to an available port

**Timeout errors:**
- Increase `NAVIGATION_TIMEOUT_MS` or `ACTION_TIMEOUT_MS` in `.env`
- Check network connectivity to target URLs

**Session not found / evicted:**
- Sessions are automatically evicted after `SESSION_IDLE_TIMEOUT_S` (default 10 minutes) of inactivity
- If a session's page crashes, the server detects it and removes the session on the next access
- Simply call `session_create` to get a fresh session and retry your workflow

**JavaScript evaluation fails:**
- The function must be a valid JS expression returning a serializable value
- Example: `"() => document.title"` not `"document.title"`

## Security

This project includes several layers of security hardening:

| Control | Details |
|---------|---------|
| **API-key authentication** | Every request must carry a valid `x-api-key` header. The key is compared using `hmac.compare_digest` to prevent timing-attack side-channels. |
| **Cryptographic session IDs** | Session IDs are generated with `secrets.token_hex(16)` (128-bit), making brute-force guessing infeasible even with a valid API key. |
| **URL scheme allowlist** | `browser_navigate` only accepts `http://`, `https://`, and `data:` URLs — `file://`, `javascript:`, and other dangerous schemes are rejected. `data:` URIs are permitted to support inline HTML rendering for PDF generation workflows. |
| **Session isolation** | Each client operates in its own `BrowserContext` — cookies, localStorage, and history are fully isolated between sessions. |
| **Dead session detection** | Every tool call verifies the page is still alive before use. The background cleanup also reaps crashed/dead sessions every 60 seconds. |
| **Leak-proof creation** | If page creation fails during `session_create`, the browser context is closed immediately — no orphaned resources. |
| **TOCTOU-safe session limits** | The `MAX_SESSIONS` check and session insertion happen atomically under a single lock acquisition — no race window for exceeding the limit. |
| **Input length limits** | All string fields enforce `max_length` via Pydantic to prevent memory exhaustion from oversized payloads. |
| **Viewport bounds** | `browser_resize` limits dimensions to 7680 × 4320 (8K) to prevent unreasonable resource allocation. |
| **Race-condition guard** | Browser initialisation uses an `asyncio.Lock` with double-checked locking to prevent duplicate Chromium launches. |
| **Non-root Docker container** | The `Dockerfile` creates and switches to a dedicated `appuser` (UID 1001) so Chromium never runs as root. |
| **Minimal error disclosure** | Server misconfiguration errors return a generic message — no internal paths or config details are leaked. |

### Known trade-offs

| Item | Notes |
|------|-------|
| `--no-sandbox` Chromium flag | Required inside Docker (no kernel namespaces). Mitigated by running as non-root in an isolated container. |
| `browser_evaluate` accepts arbitrary JS | This is intentional — the tool is designed for trusted callers behind the API key. Do not expose this server to untrusted users. |
| No rate limiting | Consider adding rate-limiting middleware (e.g. `slowapi`) for public-facing deployments. |
| No HTTPS termination by default | Set `ENABLE_HTTPS=true` for self-signed TLS, or use a reverse proxy (nginx, Azure Front Door, App Service TLS) for production. |

### Generating a strong API key

```bash
python -c "import secrets; print(secrets.token_urlsafe(32))"
```

Paste the output into your `.env` file as `MCP_API_KEY=<value>`.

Below are PowerShell-first commands to build and run this project on Docker Desktop and ensure other containers in the same environment can access it. Key implementation details in code: binds to 0.0.0.0 via [uvicorn.run()](server.py:1051), reads the port from [PORT](server.py:29), requires an API key via [APIKeyMiddleware](server.py:63) reading [MCP_API_KEY](server.py:32), and exposes 8000 in the image ([EXPOSE 8000](Dockerfile:29)).

1) Build the image
- From the project root (where Dockerfile and server.py live), build a local tag:
  docker build -t playwright-mcp:local .

2) Create a user-defined bridge network (enables container-to-container DNS)
- This lets other containers reach this service by its container name instead of an IP.
  docker network create mcp-net

3) Provide the API key environment
- The server rejects requests without a valid x-api-key header enforced by [APIKeyMiddleware](server.py:63). Create a .env at the repo root:
  Set-Content -Path .env -Value "MCP_API_KEY=your-secure-random-key-here"
- Optional: add a custom port (defaults to [8000](server.py:29)) and headless setting:
  Add-Content -Path .env -Value "PORT=8000"
  Add-Content -Path .env -Value "HEADLESS=true"

4) Run the container (host-accessible AND accessible from other containers)
- Maps host port 8000 to container port 8000, attaches to the shared network, and loads .env.
  docker run --name mcp \
    --detach \
    --restart unless-stopped \
    --network mcp-net \
    --env-file .env \
    -p 8000:8000 \
    playwright-mcp:local

5) Validate from the host (PowerShell)
- Build request body to call the [health_check()](server.py:457) tool over HTTP:
  $headers = @{ 'Content-Type' = 'application/json'; 'x-api-key' = 'your-secure-random-key-here' }
  $body = @{ method = 'tools/call'; params = @{ name = 'health_check'; arguments = @{} } } | ConvertTo-Json -Depth 5
  Invoke-RestMethod -Method POST -Uri 'http://localhost:8000/mcp' -Headers $headers -Body $body
- Expected response: { status = 'healthy', ... } (browser starts only when a session is created).

6) Validate from another container on the same network
- Other containers should talk to this service via its container name mcp on port 8000 (not localhost).
  docker run --rm --network mcp-net curlimages/curl:8.6.0 \
    -s -H "Content-Type: application/json" \
    -H "x-api-key: your-secure-random-key-here" \
    -d "{\"method\":\"tools/call\",\"params\":{\"name\":\"health_check\",\"arguments\":{}}}" \
    http://mcp:8000/mcp

Variant: inter-container access only (no host port published)
- Omit -p to keep it internal; other containers in mcp-net still reach http://mcp:8000.
  docker run --name mcp \
    --detach --restart unless-stopped \
    --network mcp-net \
    --env-file .env \
    playwright-mcp:local

Changing the port (if needed)
- If you set PORT in .env (e.g., 8931), the container listens on that port per [PORT](server.py:29). Publish and target the same container port:
  Add-Content -Path .env -Value "PORT=8931"
  docker run --name mcp --detach --network mcp-net --env-file .env -p 8931:8931 playwright-mcp:local
- Other containers in mcp-net then use http://mcp:8931/mcp.

Operational diagnostics and logs
- Follow logs to verify startup; you should see the line emitted at app start ["Starting Playwright MCP server on 0.0.0.0:{port}"](server.py:1024):
  docker logs -f mcp
- Confirm the container is on the shared network and has a stable name:
  docker inspect mcp --format '{{json .NetworkSettings.Networks}}' | Out-String
  docker network inspect mcp-net | Out-String
- If requests fail, ensure the x-api-key header matches the .env value and you are posting to /mcp.

Cleanup
- Stop and remove the container, remove the network, optionally remove the image:
  docker rm -f mcp
  docker network rm mcp-net
  docker rmi playwright-mcp:local

Common pitfalls (and quick checks)
- Missing MCP_API_KEY ⇒ All requests get 500 via [APIKeyMiddleware](server.py:63). Check .env and -Headers 'x-api-key'.
- Using localhost from another container ⇒ Use http://mcp:8000 (container name on user-defined network), not localhost.
- Wrong port mapping when changing PORT ⇒ If PORT!=8000 in the container, publish -p hostPort:containerPort accordingly.
- Docker Desktop set to Windows containers ⇒ This image is Linux-based ([Dockerfile](Dockerfile)); switch to Linux containers.
- Not on a user-defined network ⇒ Create mcp-net and attach both the service and clients for DNS-based service discovery.

What this enables
- Host access: http://localhost:8000/mcp
- Inter-container access: http://mcp:8000/mcp (or the port you configured) within the mcp-net network.

References in the code for clarity
- Binding and server start: [uvicorn.run()](server.py:1051), startup log line [logger.info](server.py:1024)
- Port selection: [PORT env read](server.py:29)
- API key enforcement: [APIKeyMiddleware](server.py:63) reading [MCP_API_KEY](server.py:32)
- Container port exposure: [EXPOSE 8000](Dockerfile:29)

This set of commands deploys the service to Docker Desktop and configures networking so other containers in the same environment can reliably reach it by container name on the shared bridge network.

## OpenTelemetry / Distributed Tracing

The server includes built-in OpenTelemetry instrumentation for distributed
tracing. When a calling service (e.g. a Node.js RAG API) sends a W3C
`traceparent` header with its MCP tool calls, the Playwright server
automatically continues the trace -- so you see the full request flow
across services in Splunk APM, Jaeger, or any OTLP-compatible backend.

### How it works

1. **ASGI middleware** (`OpenTelemetryMiddleware`) extracts `traceparent`
   and `tracestate` from incoming HTTP headers and creates a server span
   linked to the caller's trace.
2. **Tool-level spans** -- each MCP tool function is wrapped with a
   `@traced_tool` decorator that creates a child span named
   `mcp.tool.<function_name>` (e.g. `mcp.tool.browser_navigate`).
3. **OTLP export** -- when `OTEL_EXPORTER_OTLP_ENDPOINT` is set, spans
   are exported via OTLP/HTTP to the configured OTel Collector.

### Enabling tracing

Add these to your `.env`:

```env
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_SERVICE_NAME=playwright-mcp
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=dev
```

### Startup diagnostics

On container start, the server prints a startup banner that shows whether
OTel tracing is enabled and whether the collector is reachable. This makes
it easy to verify your configuration without sending test requests.

**OTel disabled** (no endpoint configured):

```
==============================================================
  Playwright MCP Server — Startup Summary
==============================================================
  Listen address : http://0.0.0.0:8000
  ...
--------------------------------------------------------------
  OpenTelemetry (Distributed Tracing)
--------------------------------------------------------------
  Status         : DISABLED
  Detail         : OTEL_EXPORTER_OTLP_ENDPOINT is not set.
  To enable      : Set OTEL_EXPORTER_OTLP_ENDPOINT in your
                   .env file (e.g. http://localhost:4318).
==============================================================
```

**OTel enabled and connected**:

```
--------------------------------------------------------------
  OpenTelemetry (Distributed Tracing)
--------------------------------------------------------------
  Status         : ✅ ENABLED & CONNECTED
  Endpoint       : http://otel-collector:4318
  Service name   : playwright-mcp
  Traces will be exported via OTLP/HTTP.
==============================================================
```

**OTel enabled but collector unreachable** (with debug hints):

```
--------------------------------------------------------------
  OpenTelemetry (Distributed Tracing)
--------------------------------------------------------------
  Status         : ⚠️  ENABLED but COLLECTOR UNREACHABLE
  Endpoint       : http://otel-collector:4318
  Error          : Connection failed: [Errno 111] Connection refused

  Debug checklist:
    1. Is the OTel Collector running?
    2. Is the endpoint correct in .env?
    3. Is there a firewall or network policy blocking traffic?
    4. If using Docker Compose, are both services on the same network?
==============================================================
```

### Trace structure

When called from another instrumented service, a single trace shows:

```
[calling-service] POST /mcp (HTTP client span)
  |
  +-- [playwright-mcp] POST /mcp (ASGI server span, auto)
      |
      +-- [playwright-mcp] mcp.tool.browser_navigate (manual span)
```

### Without tracing

If `OTEL_EXPORTER_OTLP_ENDPOINT` is not set, the OTel SDK initializes
in no-op mode -- zero overhead, no traces exported. The server works
exactly as before.

## References

- [Playwright Python Docs](https://playwright.dev/python/)
- [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)
- [MCP Protocol Spec](https://modelcontextprotocol.io/)
- [Azure App Service Deployment](https://learn.microsoft.com/en-us/azure/app-service/)
- [OpenTelemetry Python SDK](https://opentelemetry.io/docs/languages/python/)
- [W3C Trace Context](https://www.w3.org/TR/trace-context/)