Skip to main content
Glama
daninden

playwright-mcp-azure

by daninden
README.md
# playwright-mcp-azure

A remote MCP server that gives an MCP client general-purpose headless-browser control via
[Playwright](https://playwright.dev/), exposed over Streamable HTTP and deployable to Azure
Container Apps.

A single process holds one shared headless Chromium browser. Each client connection gets its
own Playwright `BrowserContext` + active `Page`, tracked by an in-memory session manager with
idle eviction and a concurrency cap. Sessions are in-memory only — nothing persists across a
restart.

## Prerequisites

- Node.js 20+
- npm
- Docker (only needed for building/deploying the container)
- [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) (only needed for deploy)

## Local setup

```bash
npm install
npx playwright install chromium   # one-time, downloads the Chromium binary
```

The server requires a bearer token for auth:

```bash
export MCP_BEARER_TOKEN=some-local-dev-token   # PowerShell: $env:MCP_BEARER_TOKEN = "..."
npm run dev
```

This starts the server on `http://localhost:3000`, with the MCP endpoint mounted at `/mcp`
(Streamable HTTP) and an unauthenticated health check at `/healthz`.

### Environment variables

| Variable            | Required | Default  | Description                                              |
| -------------------- | -------- | -------- | ---------------------------------------------------------- |
| `MCP_BEARER_TOKEN`   | yes      | —        | Bearer token clients must send as `Authorization: Bearer <token>` on `/mcp`. |
| `IDLE_TIMEOUT_MS`    | no       | `600000` | Milliseconds of inactivity before a session's browser context is closed.    |
| `MAX_SESSIONS`       | no       | `5`      | Maximum concurrent browser sessions.                        |
| `PORT`               | no       | `3000`   | Port the HTTP server listens on.                            |
| `PWD_<hostname>`     | no       | —        | Password `browser_login` fills on `https://<hostname>` pages, e.g. `PWD_www.linkedin.com`. Values shorter than 6 characters are ignored with a warning. |

### Running tests

```bash
npm test
```

Integration tests launch real headless Chromium instances, so the Playwright browser install
step above is required first.

### Building

```bash
npm run build   # compiles src/ to dist/
npm start        # runs the compiled server (dist/server.js)
```

## Connecting an MCP client

Point an MCP client that supports Streamable HTTP at `http://<host>:<port>/mcp`, sending
`Authorization: Bearer <MCP_BEARER_TOKEN>` on every request. The server exposes these tools:

`browser_start_session`, `browser_navigate`, `browser_navigate_back`, `browser_click`,
`browser_type`, `browser_fill_form`, `browser_login`, `browser_hover`, `browser_press_key`,
`browser_select_option`, `browser_handle_dialog`, `browser_snapshot`, `browser_find`,
`browser_screenshot`, `browser_evaluate`, `browser_wait_for`, `browser_tabs`,
`browser_network_requests`, `browser_console_messages`, `browser_close`.

Browser sessions are identified by an app-level `sessionId`, not by the MCP transport's own
session id -- some MCP clients (notably Azure AI Foundry's agent tool integration) open a
brand-new transport connection for every single tool call and never reuse the transport-level
session, so nothing tied to that id would survive between calls. Call `browser_start_session`
once at the start of a task to get a `sessionId`, then pass that same `sessionId` as an argument
on every other `browser_*` call for that task. If a call returns an error naming
`browser_start_session`, the session expired or was closed (idle timeout, or an explicit
`browser_close`) -- start a new one and resume from there.

`browser_snapshot` returns a role/name listing of interactive elements, each stamped with a
ref (e.g. `e3`); pass that ref to `browser_click`/`browser_type`/`browser_fill_form` to act on
that element.

`browser_network_requests` records XHR and fetch GET/POST requests across every tab in the
session (up to the last 200; older entries drop off). Call it with `{ "action": "list" }` for
`{ url, method, requestBody, status, responseBody }` per request, optionally adding
`"urlPattern": "*/api/*"` to filter by a `*`-wildcard match on the URL, or
`{ "action": "clear" }` to empty the log.

### Logging in without exposing the password

`browser_login` fills the current page's password field with the password stored for that
page's hostname, without the value ever reaching the model. It takes only a `sessionId`: the
hostname of the current page selects the secret. It refuses — filling nothing and saying why —
unless all three hold:

- the page is served over **https**;
- a `PWD_<hostname>` secret exists for the page's **exact** hostname (no parent-domain
  fallback: a secret for `linkedin.com` is not used on `www.linkedin.com`);
- the page's main frame shows **exactly one** visible `input[type="password"]`.

The tool only fills the password. Type the username yourself with `browser_type` first, and
submit the form yourself afterwards.

Every stored password is also a redaction pattern. Before any tool result leaves the server,
each text item is scanned for every stored value — raw, JSON-escaped, and percent-encoded —
and each occurrence is replaced with `[REDACTED]`. That covers `browser_snapshot`,
`browser_find`, `browser_evaluate`, `browser_network_requests` (whose captured request and
response bodies would otherwise contain the login POST), error messages, and the server's own
logs. Screenshots are not scrubbed, but browsers mask password inputs themselves.

Tool failures are never reported via the MCP protocol's `isError` flag -- Azure AI Foundry's
agent orchestration treats an `isError` result as a fatal tool-call failure and stops the run
instead of letting the model see and react to it. Instead, a failed call still returns a normal
result whose content is a JSON object like `{ "error": "<description>" }`, so the model can read
the error and decide how to proceed (retry, call `browser_start_session` again, etc.).

## Deploying to Azure Container Apps

Infrastructure (`infra/main.bicep`) provisions an Azure Container Registry, a Log Analytics
workspace, a Container Apps environment, and a single Container App (HTTPS-only ingress,
pinned to 1 replica). It needs a container image to deploy, and the registry doesn't exist
until the infra is applied — so the first deploy provisions with a placeholder image, then
`infra/deploy.ps1` builds and pushes the real one.

1. **Log in and create a resource group** (skip if you already have one):

   ```bash
   az login
   az group create --name rg-playwright-mcp --location westeurope
   ```

2. **Provision the infrastructure** with a placeholder image:

   ```bash
   az deployment group create \
     --resource-group rg-playwright-mcp \
     --template-file infra/main.bicep \
     --parameters mcpBearerToken=<a-strong-random-token> \
                  containerImage=mcr.microsoft.com/k8se/quickstart:latest
   ```

   Note the `acrLoginServer` output (or a name prefix segment of it) — that's the `-AcrName`
   value for the next step.

3. **Build, test, and deploy the real image**:

   ```powershell
   ./infra/deploy.ps1 -ResourceGroup rg-playwright-mcp -AcrName <acr-name-from-step-2>
   ```

   This runs `npm ci`, installs the Chromium browser, builds, runs the test suite, then
   builds and pushes a Docker image and updates the Container App to use it. Re-run this
   script for subsequent deploys — the infra step only needs to run once (or again if
   `infra/main.bicep` changes).

4. **Get the URL**:

   ```bash
   az containerapp show --name playwright-mcp-app --resource-group rg-playwright-mcp \
     --query properties.configuration.ingress.fqdn -o tsv
   ```

   The MCP endpoint is `https://<fqdn>/mcp`.

To change `IDLE_TIMEOUT_MS`, `MAX_SESSIONS`, or `PORT` in Azure, edit their values in
`infra/main.bicep`'s container `env` block and redeploy the infra step.

### Storing a password for `browser_login`

```powershell
./infra/set-login-secret.ps1 -ResourceGroup rg-playwright-mcp -Domain www.linkedin.com
```

It prompts for the password (so it stays out of your shell history), stores it as a Container
Apps secret named `pwd-www-linkedin-com`, and points the `PWD_www.linkedin.com` environment
variable at that secret. Both steps create a new revision, which restarts the container — the
server reads its secrets once at startup. Add `-Remove` to delete a stored password.

> **Warning:** stored passwords do not survive a bicep deploy. `infra/main.bicep` declares the
> full `secrets` and `env` arrays, so re-running the `az deployment group create` step in this
> section resets them to what the template lists and drops every `pwd-*` secret. Re-run
> `set-login-secret.ps1` for each domain afterwards. `infra/deploy.ps1` is safe — it only calls
> `az containerapp update --image`, which leaves secrets and environment variables untouched.