8000 feat(assistant): turn the MCP runner and a browser on from the web app, with ready-made MCP servers by adeelahmad · Pull Request #491 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3ff45d1
feat(assistant): turn the MCP runner and a browser on from the web ap…
claude Oct 17, 2026
96e8835
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 17, 2026
969c44d
test(sources): give the routing rules preview time to show on a busy …
claude Oct 17, 2026
cea00c4
Merge remote-tracking branch 'origin/main' into port-c6b
claude Oct 17, 2026
8bb51af
Merge origin/main into claude/project-thread-r960xc-c6-browser
claude Oct 17, 2026
1b20431
fix(browser): give the browser's home in memory to the user it runs as
claude Oct 17, 2026
4327319
Merge origin/main into claude/project-thread-r960xc-c6-browser
claude Oct 17, 2026
bc73884
fix(ci): the upgrade check counts entities of every type
claude Oct 17, 2026
30b314b
Merge remote-tracking branch 'origin/main' into port-c6b
claude Oct 17, 2026
20c9026
Merge remote-tracking branch 'origin/main' into port-c6b
claude Oct 17, 2026
118fc53
Merge remote-tracking branch 'origin/main' into port-c6b
claude Oct 17, 2026
e5d2585
Merge remote-tracking branch 'origin/main' into port-c6b
claude Oct 17, 2026
00e593a
Merge remote-tracking branch 'origin/main' into port-c6b
claude Oct 17, 2026
5aa4bc9
Merge remote-tracking branch 'origin/main' into port-c6b
claude Oct 17, 2026
1e1604e
Merge remote-tracking branch 'origin/main' into port-c6b
claude Oct 17, 2026
a7b3381
Merge remote-tracking branch 'origin/main' into port-c6b
claude Oct 17, 2026
787851f
Merge remote-tracking branch 'origin/main' into port-c6b
claude Oct 17, 2026
7035428
Merge remote-tracking branch 'origin/main' into port-c6b
claude Oct 18, 2026
d6b4aa3
Merge remote-tracking branch 'origin/main' into port-c6b
claude Oct 18, 2026
6f0bdc2
Merge remote-tracking branch 'origin/main' into port-c6b
claude Oct 18, 2026
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
113 changes: 113 additions & 0 deletions
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
name: Browser

# The assistant's browser (the optional browser profile: Playwright MCP and netguard's proxy, docs/assistant.md): it
# starts as docker-compose.prod.yml runs it, lists its tools over MCP, opens a public page that stays open from one
# session to the next, can't reach a private address through its proxy, and has no way out of its network but that.
on:
pull_request:
paths:
- browser/**
- fastapi_backend/app/domain/netguard.py
- docker-compose.prod.yml
- .github/workflows/browser.yml
push:
branches: [main]
paths:
- browser/**
- fastapi_backend/app/domain/netguard.py
- docker-compose.prod.yml
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
contents: read

jobs:
browser:
name: Start it and browse
runs-on: ${{ fromJSON(vars.CI_RUNNER || '"ubuntu-latest"') }}
timeout-minutes: 20
env:
# the compose file needs these set to be read; the browser uses none of them
ACCESS_SECRET_KEY: ci
ARCHIVE_SECRET_KEY: ci
SURREAL_PASS: ci
AUTH_SECRET: ci
steps:
- uses: actions/checkout@v4

- name: Start it as Lens's compose file does
run: |
docker compose -p lensci -f docker-compose.prod.yml --profile browser up -d browser browser-proxy
sleep 5
docker compose -p lensci -f docker-compose.prod.yml ps -a

- name: Lists its tools, and browses a public page
run: |
# mcp BODY: one request to the browser, from a container on its network as the backend is; with $SID, in
# that session
mcp() {
docker run --rm --network lensci_browser curlimages/curl:8.10.1 -sS -i --max-time 120 -X POST \
http://browser:8931/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
${SID:+-H "Mcp-Session-Id: $SID"} -d "$1"
}
open_session() {
SID=""
out="$(mcp '{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "ci", "version": "1"}}}')"
SID="$(printf '%s' "$out" | tr -d '\r' | sed -n 's/^[Mm]cp-[Ss]ession-[Ii]d: //p')"
test -n "$SID" || { echo "$out"; return 1; }
mcp '{"jsonrpc": "2.0", "method": "notifications/initialized"}' >/dev/null
}
call() { # tool arguments
mcp "{\"jsonrpc\": \"2.0\", \"id\": 2, \"method\": \"tools/call\", \"params\": {\"name\": \"$1\", \"arguments\": $2}}"
}
for _ in $(seq 1 30); do
open_session 2>/dev/null && break
sleep 2
done
test -n "$SID"
mcp '{"jsonrpc": "2.0", "id": 3, "method": "tools/list"}' | tee out.txt
grep -q browser_navigate out.txt && grep -q browser_snapshot out.txt
call browser_navigate '{"url": "https://example.com"}' | tee out.txt
grep -q "Example Domain" out.txt
# another session sees the same page: the one browser, shared (Lens's backend and worker each keep one)
open_session
call browser_snapshot '{}' | tee out.txt
grep -q "Example Domain" out.txt

- name: Can't reach a private address, nor out but through its proxy
run: |
mcp() {
docker run --rm --network lensci_browser curlimages/curl:8.10.1 -sS -i --max-time 120 -X POST \
http://browser:8931/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
${SID:+-H "Mcp-Session-Id: $SID"} -d "$1"
}
SID=""
out="$(mcp '{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "ci", "version": "1"}}}')"
SID="$(printf '%s' "$out" | tr -d '\r' | sed -n 's/^[Mm]cp-[Ss]ession-[Ii]d: //p')"
mcp '{"jsonrpc": "2.0", "method": "notifications/initialized"}' >/dev/null
mcp '{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "browser_navigate", "arguments": {"url": "https://169.254.169.254/latest/meta-data/"}}}' | tee out.txt
grep -q ERR_TUNNEL_CONNECTION_FAILED out.txt
mcp '{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "browser_navigate", "arguments": {"url": "http://browser-proxy/"}}}' >/dev/null
docker compose -p lensci -f docker-compose.prod.yml logs browser-proxy | tee out.txt
grep -q "refused CONNECT 169.254.169.254:443" out.txt
grep -q "refused GET http://browser-proxy/: browser-proxy isn't a public address" out.txt
# straight out of the browser's container, past its proxy: no way
docker compose -p lensci -f docker-compose.prod.yml exec -T browser node -e "
require('https').get('https://example.com', () => { console.log('reached the internet'); process.exit(1) })
.on('error', (e) => { console.log('no way out:', e.code); process.exit(0) })"
# and only the backend's name for it is served
code="$(docker run --rm --network lensci_browser curlimages/curl:8.10.1 -s -o /dev/null -w '%{http_code}' \
-X POST -H 'Host: evil.example:8931' http://browser:8931/mcp -d '{}')"
test "$code" = 403

- name: What they said
if: failure()
run: docker compose -p lensci -f docker-compose.prod.yml logs

- name: Stop
if: always()
run: docker compose -p lensci -f docker-compose.prod.yml --profile browser down -v || true
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,14 @@ The backend (`fastapi_backend`) and the frontend (`nextjs-frontend`) are version
sealed and never shown again (manifests say `••••`), and an upgrade step seals the ones saved before. Each server's
tools have a switch and "Asks first", kept in its manifest
([Adding an MCP server](docs/assistant.md#adding-an-mcp-server)).
- **A browser for the assistant, and ready-made MCP servers.** Add MCP server now starts with Ready-made:
**Browser** runs a headless Chromium ([Playwright MCP](https://github.com/microsoft/playwright-mcp)) in a container
of its own, whose only way out is a proxy to public addresses on ports 80 and 443, and adds it with one click;
**GitHub** fills in GitHub's own server with a field for its token. Values written as `${NAME}` in a pasted config
now get a field of their own too. The MCP runner, the browser and monitoring turn on and off from the web app (the
machine's upgrade agent starts and stops them), and agents do the same with `PUT /api/v1/admin/services/<profile>`
or the MCP tools `services` and `set_service`. A server whose spec says `keep: true` keeps one session between
calls ([The browser](docs/assistant.md#the-browser), [Optional containers](docs/assistant.md#optional-containers)).
- **Describe an automation in words.** `POST /api/v1/automations/draft` asks the model once to turn a sentence into
a draft automation to try and save, and says what didn't fit and what it couldn't express
([Describe it in words](docs/automations.md#describe-it-in-words)).
Expand Down
7 changes: 7 additions & 0 deletions browser/playwright-mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"browser": {
"launchOptions": {
"args": ["--force-webrtc-ip-handling-policy=disable_non_proxied_udp", "--disable-dev-shm-usage"]
}
}
}
60 changes: 59 additions & 1 deletion docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ x-backend: &backend
# the MCP runner (the optional `mcp` service, docs/assistant.md): MCP servers that run as a program, sandboxed there
LENS_MCP_RUNNER_URL: http://mcp:8090
LENS_MCP_TOKEN_FILE: /mcp-key/token
# the assistant's browser (the optional `browser` service, docs/assistant.md): Playwright MCP
LENS_BROWSER_URL: http://browser:8931
# the code sandbox's gateway (the optional `sandbox` profile, docs/assistant.md#code-sandbox): the client
# certificate Lens signs in to it with
LENS_SANDBOX_TLS_DIR: /sandbox-key
Expand All @@ -98,7 +100,7 @@ x-backend: &backend
surrealdb:
condition: service_healthy
restart: unless-stopped
networks: [lens, mcp, sandbox]
networks: [lens, mcp, browser, sandbox]

services:
surrealdb:
Expand Down Expand Up @@ -216,6 +218,55 @@ services:
restart: unless-stopped
networks: [mcp]

# The assistant's browser (docs/assistant.md#the-browser): headless Chromium the assistant drives over MCP
# (Playwright MCP), added in Extensions › Add MCP server › Browser. Opt-in, about 400 MB to download: that page turns
# it on (the upgrade agent adds `browser` to COMPOSE_PROFILES and starts it and its proxy), or
# `docker compose --profile browser up -d`. The sandbox: none of Lens's data or keys, no capabilities, a read-only
# image, its profile in memory only, and a network with no way out but browser-proxy, which reaches public web
# addresses only, on ports 80 and 443 (fastapi_backend/app/domain/netguard.py). One browser for everyone who uses it.
browser:
image: mcr.microsoft.com/playwright/mcp:${PLAYWRIGHT_MCP_VERSION:-v0.0.83}
profiles: [browser]
command:
- --port=8931
- --host=0.0.0.0
- --allowed-hosts=browser:8931
- --shared-browser-context
- --proxy-server=http://browser-proxy:3128
- --image-responses=omit
- --timeout-navigation=45000
- --output-dir=/tmp/output
- --config=/etc/lens/playwright-mcp.json
stdin_open: true # it stops when its input closes
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
read_only: true
tmpfs: [/tmp, "/home/node:uid=1000,gid=1000"] # the image runs as node (1000); its profile lives in its home
mem_limit: 1g
pids_limit: 512
volumes:
- ./browser/playwright-mcp.json:/etc/lens/playwright-mcp.json:ro
depends_on: [browser-proxy]
restart: unless-stopped
networks: [browser]

# The browser's only way out: netguard's proxy on its own (the standard library only), to public addresses on the
# web's ports. Nothing else: no data, no keys, nobody's user.
browser-proxy:
image: python:3.12-slim-trixie
profiles: [browser]
command: ["python", "-I", "/netguard/netguard.py", "--listen", "0.0.0.0:3128"]
user: "65534:65534"
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
read_only: true
mem_limit: 256m
pids_limit: 256
volumes:
- ./fastapi_backend/app/domain/netguard.py:/netguard/netguard.py:ro
restart: unless-stopped
networks: [browser, browser-out]

# The code sandbox's NVIDIA OpenShell gateway (docs/assistant.md#code-sandbox): Python and bash for the assistant, one
# sandbox container per conversation, made through this machine's Docker. Opt-in: Settings → Code sandbox → Start
# the gateway (the upgrade agent adds `sandbox` to COMPOSE_PROFILES and starts these), or
Expand Down Expand Up @@ -415,5 +466,12 @@ networks:
driver: bridge
mcp:
driver: bridge
# the browser, the backend and the browser's proxy: no way out of it (internal)
browser:
driver: bridge
internal: true
# the browser's proxy to the internet
browser-out:
driver: bridge
sandbox:
driver: bridge
50 changes: 49 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ x-backend: &backend
# the MCP runner (the optional `mcp` service, docs/assistant.md): MCP servers that run as a program, sandboxed there
LENS_MCP_RUNNER_URL: http://mcp:8090
LENS_MCP_TOKEN_FILE: /mcp-key/token
# the assistant's browser (the optional `browser` service, docs/assistant.md): Playwright MCP
LENS_BROWSER_URL: http://browser:8931
# the code sandbox's gateway (the optional `sandbox` profile, docs/assistant.md#code-sandbox): the client
# certificate Lens signs in to it with
LENS_SANDBOX_TLS_DIR: /sandbox-key
Expand All @@ -66,7 +68,7 @@ x-backend: &backend
depends_on:
surrealdb:
condition: service_healthy
networks: [lens, mcp, sandbox]
networks: [lens, mcp, browser, sandbox]

services:
surrealdb:
Expand Down Expand Up @@ -188,6 +190,47 @@ services:
- mcp-key:/key
networks: [mcp]

# The assistant's browser and its proxy, as in docker-compose.prod.yml (docs/assistant.md#the-browser). Opt-in:
# `docker compose --profile browser up -d`.
browser:
image: mcr.microsoft.com/playwright/mcp:${PLAYWRIGHT_MCP_VERSION:-v0.0.83}
profiles: [browser]
command:
- --port=8931
- --host=0.0.0.0
- --allowed-hosts=browser:8931
- --shared-browser-context
- --proxy-server=http://browser-proxy:3128
- --image-responses=omit
- --timeout-navigation=45000
- --output-dir=/tmp/output
- --config=/etc/lens/playwright-mcp.json
stdin_open: true # it stops when its input closes
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
read_only: true
tmpfs: [/tmp, "/home/node:uid=1000,gid=1000"] # the image runs as node (1000); its profile lives in its home
mem_limit: 1g
pids_limit: 512
volumes:
- ./browser/playwright-mcp.json:/etc/lens/playwright-mcp.json:ro
depends_on: [browser-proxy]
networks: [browser]

browser-proxy:
image: python:3.12-slim-trixie
profiles: [browser]
command: ["python", "-I", "/netguard/netguard.py", "--listen", "0.0.0.0:3128"]
user: "65534:65534"
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
read_only: true
mem_limit: 256m
pids_limit: 256
volumes:
- ./fastapi_backend/app/domain/netguard.py:/netguard/netguard.py:ro
networks: [browser, browser-out]

# The code sandbox's NVIDIA OpenShell gateway (docs/assistant.md#code-sandbox): Python and bash for the assistant, one
# sandbox container per conversation, made through this machine's Docker. Opt-in: Settings → Code sandbox → Start
# the gateway (the upgrade agent adds `sandbox` to COMPOSE_PROFILES and starts these), or
Expand Down Expand Up @@ -308,5 +351,10 @@ networks:
driver: bridge
mcp:
driver: bridge
browser:
driver: bridge
internal: true # no way out but browser-proxy
browser-out:
driver: bridge
sandbox:
driver: bridge
Loading
Loading
0