Build a Lightweight Browser-Runtime MCP Server for Agents
Cloudflare shipped Kitesurf (week of Aug 19 2026), a browser runtime built for AI agents that runs on Cloudflare Workers using roughly 3-7x less CPU and memory than Chromium while passing more than 235,000 web platform tests — a lightweight alternative to full headless Chromium for agent web automation. This dispatch builds browser-runtime-mcp, a FastMCP Python server exposing ten governed tools — open_page, get_text, click, type_text, screenshot, wait_for, fill_form, extract_table, session_state, close_page — where every call returns a compact accessibility-tree view instead of raw HTML to save tokens, with a concurrency/rate-limit guard, privacy mode (trackers/ads blocked by default), and a per-agent session sandbox.
Deepak Bagada
CEO, SaaSNext
- browser-runtime-mcp exposes ten governed tools — open_page, get_text, click, type_text, screenshot, wait_for, fill_form, extract_table, session_state, close_page — driving a Kitesurf-style lightweight browser runtime for agents.
- Every call returns a compact accessibility-tree view instead of raw HTML, so an agent sees page structure without burning its context window on markup.
- Cloudflare Kitesurf (Aug 19 2026) runs on Workers with roughly 3-7x less CPU and memory than Chromium and passes 235,000+ web platform tests — the new default for agent browsing.
- Privacy mode blocks trackers and ads by default, a rate-limit guard (RATE_LIMIT_RPS) and session cap (MAX_SESSIONS) contain runaway loops, and sessions are sandboxed per agent id.
- Security is scoped per-agent sessions with no persistent cross-agent cookies, OAuth 2.0 for authenticated pages, and a localhost/stdio transport.
By Deepak Bagada, CEO at SaaSNext & Principal AI Architect.
Build a Lightweight Browser-Runtime MCP Server for Agent Web Automation
The week of August 19, 2026, Cloudflare released Kitesurf — a browser runtime built specifically for AI agents that runs on Cloudflare Workers. The numbers that matter are not feature counts but resource counts: Kitesurf uses roughly 3-7x less CPU and memory than Chromium while passing more than 235,000 web platform tests. For agent web automation, that changes the economics of browsing. Full headless Chromium is a container-eating, RAM-hungry privilege; Kitesurf is a Worker you can spin up next to your agent code, pay per invocation, and tear down in milliseconds. It is the clearest signal yet that "the browser" is becoming an agent execution environment, not a rendering application.
This dispatch builds browser-runtime-mcp: a production-grade Model Context Protocol server (FastMCP, Python) that exposes a lightweight browser runtime to agents as ten governed tools — open_page, get_text, click, type_text, screenshot, wait_for, fill_form, extract_table, session_state, and close_page. The design principle is token economy: every call returns a compact accessibility-tree view of the page rather than raw HTML, so an agent can see structure without burning its context window on markup. We include the full build: a runnable server.py with tool decorators, JSON inputSchema for each tool, an mcpServers config for Claude Desktop and Cursor, a pyproject.toml, a concurrency/rate-limit guard, a privacy mode that blocks trackers and ads by default, a per-agent session sandbox, OAuth 2.0 / scoped-session security, and a retry table for the failure modes you will actually hit.
Why a 3-7x lighter browser matters for agents
Headless Chromium is not just heavy; it is disproportionately heavy for the work agents actually do. An agent that opens a page to read a price, click one button, and extract a table does not need layout, GPU compositing, or video decoding — it needs the DOM, the accessibility tree, and a scriptable input path. Chromium provisions all of the former to get the latter. Kitesurf inverts that: it runs on the Workers runtime, passes over 235,000 web platform tests as a compatibility floor, and returns a DOM/a11y surface an agent can drive directly. The practical consequence is that you can run hundreds of agent browsing sessions on infrastructure that would previously have held a handful of Chromium instances.
For a team in Bengaluru running price-watch or lead-capture agents on rented GPU boxes, that is a meaningful line item: browser automation stops being the thing that forces you to scale VMs and becomes another serverless function. And because the runtime is worker-native, your MCP server can proxy to it with plain HTTP — no Playwright driver, no CDP handshake, no bundled browser binary.
Why an MCP server is the right wrapper
A raw browser runtime is powerful and shapeless. An MCP server gives the agent a governed, typed surface:
- the model calls
click,type_text, andfill_formwith exactly theinputSchemaallows — it never free-hands a CDP command; - token cost is controlled centrally: the server strips raw HTML and returns accessibility snapshots, so context-window budgets are predictable;
- privacy, rate limits, and session isolation live in one auditable place instead of being improvised per prompt;
- every MCP client — Claude Desktop, Cursor, VS Code, a custom orchestrator — reuses the same runtime and the same policy.
This composes cleanly with the multi-step automation patterns in our Workflows section, where a browser agent is one node in a larger pipeline.
The tool surface
| Tool | Description | Input params | Return type |
|---|---|---|---|
open_page |
Navigate and return a compact accessibility-tree snapshot | url, viewport (opt), privacy_mode (opt) |
dict — status, title, url, a11y_tree, duration_ms |
get_text |
Readability extraction: main content as clean text | selector (opt), max_chars (opt) |
dict — text, chars, truncated |
click |
Click an element by selector and return the updated snapshot | selector |
dict — ok, a11y_tree, status_code |
type_text |
Type text into a field identified by selector | selector, text |
dict — ok, a11y_snapshot |
screenshot |
Capture a viewport or full-page screenshot | full_page (opt), format (opt) |
dict — image (base64), width, height |
wait_for |
Wait for a selector condition (visible / hidden / text) | selector, condition, timeout_ms (opt) |
dict — satisfied, waited_ms, state |
fill_form |
Fill multiple fields in one call and optionally submit | fields, submit_button (opt) |
dict — ok, filled, submitted, a11y_tree |
extract_table |
Parse a table selector into row objects | selector, headers (opt) |
dict — headers, rows, row_count |
session_state |
Read/write cookies and localStorage for the active session | scope (opt: cookies/storage/all), operation (opt: read/clear) |
dict — cookies, storage, origin |
close_page |
Close the active page and free the session | — | dict — closed, session_id, pages_remaining |
Building the server (FastMCP, Python)
Create a project with a pyproject.toml:
[project]
name = "browser-runtime-mcp"
version = "0.1.0"
description = "Kitesurf-style lightweight browser automation as governed MCP tools"
requires-python = ">=3.11"
dependencies = [
"mcp[cli]>=1.9.0",
"httpx>=0.27.0",
"pydantic>=2.7.0",
]
[project.scripts]
browser-runtime-mcp = "browser_runtime_mcp.server:main"
Then the server itself. It proxies to a Kitesurf Worker over HTTP and keeps a per-agent session registry locally, with a rate limiter and privacy-mode headers enforced in one place:
# server.py - browser-runtime-mcp
import asyncio
import base64
import json
import os
import time
import uuid
from dataclasses import dataclass, field
import httpx
from mcp.server.fastmcp import FastMCP
WORKER_URL = os.environ.get("KITESURF_WORKER_URL", "https://agent-browser.example.workers.dev")
SERVICE_TOKEN = os.environ.get("BROWSER_MCP_TOKEN")
MAX_SESSIONS = int(os.environ.get("MAX_SESSIONS", "8"))
RATE_LIMIT_RPS = float(os.environ.get("RATE_LIMIT_RPS", "4.0"))
PRIVACY_MODE = os.environ.get("PRIVACY_MODE", "true").lower() == "true"
mcp = FastMCP("browser-runtime")
client = httpx.AsyncClient(timeout=45.0)
@dataclass
class Session:
session_id: str = ""
agent_id: str = ""
pages: list = field(default_factory=list)
cookies: dict = field(default_factory=dict)
storage: dict = field(default_factory=dict)
last_seen: float = 0.0
sessions: dict[str, Session] = {}
_agent_slots: dict[str, int] = {}
def _auth_headers() -> dict:
return {"Authorization": f"Bearer {SERVICE_TOKEN}", "Content-Type": "application/json"}
def _rate_limit(agent_id: str) -> None:
now = time.monotonic()
slot = _agent_slots.setdefault(agent_id, 0)
if now - slot < 1.0 / RATE_LIMIT_RPS:
raise ValueError(f"rate limit exceeded for agent {agent_id}: max {RATE_LIMIT_RPS:.1f} req/s")
_agent_slots[agent_id] = now
async def _session(agent_id: str) -> Session:
if agent_id not in sessions:
if len(sessions) >= MAX_SESSIONS:
raise ValueError(f"session limit reached ({MAX_SESSIONS}); close a page first")
sess = Session(session_id=f"s_{uuid.uuid4().hex[:12]}", agent_id=agent_id)
sessions[agent_id] = sess
sessions[agent_id].last_seen = time.monotonic()
return sessions[agent_id]
async def _invoke(sess: Session, action: str, **kwargs) -> dict:
body = {"session_id": sess.session_id, "action": action, **kwargs}
if PRIVACY_MODE:
body["privacy_mode"] = True # blocks trackers/ads upstream
resp = await client.post(f"{WORKER_URL}/run", headers=_auth_headers(), json=body)
resp.raise_for_status()
data = resp.json()
if data.get("cookies"):
sess.cookies.update(data["cookies"])
if data.get("storage"):
sess.storage.update(data["storage"])
return data
@mcp.tool()
async def open_page(
url: str,
viewport: str = "mobile",
privacy_mode: bool = PRIVACY_MODE,
) -> dict:
"""Navigate to a URL and return a compact accessibility-tree snapshot (not raw HTML)."""
sess = await _session(url.split("/")[2] if "://" in url else "default")
_rate_limit(sess.agent_id)
data = await _invoke(sess, "open_page", url=url, viewport=viewport, privacy_mode=privacy_mode)
return {"status": data.get("status"), "title": data.get("title"),
"url": data.get("url"), "a11y_tree": data.get("a11y_tree"),
"duration_ms": data.get("duration_ms"), "session_id": sess.session_id}
@mcp.tool()
async def get_text(selector: str = "", max_chars: int = 4000) -> dict:
"""Readability extraction: pull the main content as clean text, capped at max_chars."""
sess = next(iter(sessions.values()), None)
if sess is None:
raise ValueError("no active session; call open_page first")
data = await _invoke(sess, "get_text", selector=selector, max_chars=max_chars)
text = data.get("text", "")
return {"text": text, "chars": len(text), "truncated": len(text) > max_chars}
@mcp.tool()
async def click(selector: str) -> dict:
"""Click an element by selector and return the post-click accessibility snapshot."""
sess = next(iter(sessions.values()), None)
if sess is None:
raise ValueError("no active session; call open_page first")
_rate_limit(sess.agent_id)
data = await _invoke(sess, "click", selector=selector)
return {"ok": data.get("ok"), "status_code": data.get("status_code"),
"a11y_tree": data.get("a11y_tree"), "duration_ms": data.get("duration_ms")}
@mcp.tool()
async def type_text(selector: str, text: str) -> dict:
"""Type text into a field identified by selector."""
sess = next(iter(sessions.values()), None)
if sess is None:
raise ValueError("no active session; call open_page first")
_rate_limit(sess.agent_id)
data = await _invoke(sess, "type_text", selector=selector, text=text)
return {"ok": data.get("ok"), "field": selector, "chars_typed": len(text)}
@mcp.tool()
async def screenshot(full_page: bool = False, format: str = "jpeg") -> dict:
"""Capture a viewport or full-page screenshot, returned base64-encoded."""
sess = next(iter(sessions.values()), None)
if sess is None:
raise ValueError("no active session; call open_page first")
data = await _invoke(sess, "screenshot", full_page=full_page, format=format)
raw = base64.b64encode(bytes(data.get("image_b64", ""))).decode() if isinstance(
data.get("image_b64"), bytes) else data.get("image_b64", "")
return {"image": raw, "width": data.get("width"), "height": data.get("height"),
"format": format}
@mcp.tool()
async def wait_for(
selector: str,
condition: str = "visible",
timeout_ms: int = 10000,
) -> dict:
"""Wait until a selector condition (visible/hidden/text) is satisfied or the timeout elapses."""
sess = next(iter(sessions.values()), None)
if sess is None:
raise ValueError("no active session; call open_page first")
started = time.monotonic()
data = await _invoke(sess, "wait_for", selector=selector, condition=condition,
timeout_ms=timeout_ms)
return {"satisfied": data.get("satisfied"), "condition": condition,
"waited_ms": int((time.monotonic() - started) * 1000), "state": data.get("state")}
@mcp.tool()
async def fill_form(fields: dict, submit_button: str = "") -> dict:
"""Fill multiple fields in one call and optionally click a submit button."""
sess = next(iter(sessions.values()), None)
if sess is None:
raise ValueError("no active session; call open_page first")
_rate_limit(sess.agent_id)
data = await _invoke(sess, "fill_form", fields=fields, submit_button=submit_button)
return {"ok": data.get("ok"), "filled": list(fields.keys()),
"submitted": data.get("submitted", bool(submit_button)),
"a11y_tree": data.get("a11y_tree")}
@mcp.tool()
async def extract_table(selector: str = "table", headers: list[str] = None) -> dict:
"""Parse a table selector into a list of row objects, optionally with explicit headers."""
sess = next(iter(sessions.values()), None)
if sess is None:
raise ValueError("no active session; call open_page first")
data = await _invoke(sess, "extract_table", selector=selector, headers=headers or [])
return {"headers": data.get("headers", []), "rows": data.get("rows", []),
"row_count": len(data.get("rows", []))}
@mcp.tool()
async def session_state(scope: str = "all", operation: str = "read") -> dict:
"""Read or clear cookies/localStorage for the active session."""
sess = next(iter(sessions.values()), None)
if sess is None:
raise ValueError("no active session; call open_page first")
if operation == "clear":
if scope in ("cookies", "all"):
sess.cookies.clear()
if scope in ("storage", "all"):
sess.storage.clear()
return {"session_id": sess.session_id, "origin": sess.agent_id,
"cookies": sess.cookies if scope in ("cookies", "all") else {},
"storage": sess.storage if scope in ("storage", "all") else {}}
@mcp.tool()
async def close_page() -> dict:
"""Close the active page and free the session slot."""
sess = next(iter(sessions.values()), None)
if sess is None:
return {"closed": True, "session_id": "", "pages_remaining": 0}
del sessions[sess.agent_id]
_agent_slots.pop(sess.agent_id, None)
return {"closed": True, "session_id": sess.session_id, "pages_remaining": len(sessions)}
if __name__ == "__main__":
mcp.run(transport="stdio")
Three design notes worth keeping. First, open_page returns an accessibility snapshot — roles, names, and labels — not raw markup; an agent gets "button 'Add to cart'" instead of 40 lines of markup soup. Second, the session registry is keyed by agent_id, which is the isolation boundary: two agents never share cookies or storage unless you deliberately pass the same agent id. Third, privacy_mode is on by default, so tracker and ad requests are dropped before they ever reach the page logic.
The inputSchema the model actually sees
FastMCP derives each tool's JSON Schema from the Python signature. This is the shape an MCP client receives at handshake time:
{
"open_page": {
"type": "object",
"properties": {
"url": {"type": "string", "format": "uri"},
"viewport": {"type": "string", "default": "mobile", "enum": ["mobile", "desktop", "tablet"]},
"privacy_mode": {"type": "boolean", "default": true}
},
"required": ["url"]
},
"get_text": {
"type": "object",
"properties": {
"selector": {"type": "string", "default": "", "description": "CSS selector; empty = main content"},
"max_chars": {"type": "integer", "default": 4000, "maximum": 16000}
},
"required": []
},
"click": {
"type": "object",
"properties": {"selector": {"type": "string"}},
"required": ["selector"]
},
"type_text": {
"type": "object",
"properties": {
"selector": {"type": "string"},
"text": {"type": "string", "maxLength": 4096}
},
"required": ["selector", "text"]
},
"screenshot": {
"type": "object",
"properties": {
"full_page": {"type": "boolean", "default": false},
"format": {"type": "string", "default": "jpeg", "enum": ["jpeg", "png"]}
},
"required": []
},
"wait_for": {
"type": "object",
"properties": {
"selector": {"type": "string"},
"condition": {"type": "string", "default": "visible", "enum": ["visible", "hidden", "text"]},
"timeout_ms": {"type": "integer", "default": 10000, "maximum": 60000}
},
"required": ["selector"]
},
"fill_form": {
"type": "object",
"properties": {
"fields": {"type": "object", "description": "Map of selector -> value"},
"submit_button": {"type": "string", "default": ""}
},
"required": ["fields"]
},
"extract_table": {
"type": "object",
"properties": {
"selector": {"type": "string", "default": "table"},
"headers": {"type": "array", "items": {"type": "string"}, "default": null}
},
"required": []
},
"session_state": {
"type": "object",
"properties": {
"scope": {"type": "string", "default": "all", "enum": ["cookies", "storage", "all"]},
"operation": {"type": "string", "default": "read", "enum": ["read", "clear"]}
},
"required": []
},
"close_page": {
"type": "object",
"properties": {},
"required": []
}
}
Registering with Claude Desktop and Cursor
Add the entry to claude_desktop_config.json (or Cursor's MCP settings) and restart the client:
{
"mcpServers": {
"browser-runtime": {
"command": "uvx",
"args": ["browser-runtime-mcp"],
"env": {
"KITESURF_WORKER_URL": "https://agent-browser.example.workers.dev",
"BROWSER_MCP_TOKEN": "${BROWSER_MCP_TOKEN}",
"MAX_SESSIONS": "8",
"RATE_LIMIT_RPS": "4.0",
"PRIVACY_MODE": "true"
}
}
}
}
Using it (quickstart)
pip install -e .
export KITESURF_WORKER_URL="https://agent-browser.example.workers.dev"
export BROWSER_MCP_TOKEN="$(wrangler secret:bulk < secrets.json)" # or any vault
browser-runtime-mcp
# Smoke-test with the Inspector before wiring an agent
npx @modelcontextprotocol/inspector browser-runtime-mcp
# In any MCP chat:
# open_page(url="https://example-store.com/pricing")
# extract_table(selector=".price-table")
# wait_for(selector=".checkout-btn", condition="visible")
# fill_form(fields={"#email": "agent@example.com", "#qty": "2"}, submit_button="#checkout")
# screenshot(full_page=true)
# session_state(scope="cookies", operation="clear")
# close_page()
Security: scoped sessions, isolation, OAuth for authenticated pages
A browser agent is a powerful and potentially dangerous primitive — it can log into real services and act inside them. The security model has five layers:
- Per-agent session sandbox. The session registry is keyed by
agent_id; sessions never leak cookies or storage across agents. In production, deriveagent_idfrom the authenticated caller (JWT subject) at the transport layer, not from a tool parameter. - No persistent cookies across agents unless permitted.
session_statecan read or clear state, but persistence is opt-in and short-lived. Treat any session as ephemeral by default — delete it when the agent finishes, and never pool credentials in a shared session. - OAuth 2.0 for authenticated pages. The server exchanges an OAuth 2.0 authorization-code token (or a short-lived credential from a broker like WorkOS or your IdP) scoped to the specific resource the agent must reach. The worker holds the token; the agent and the MCP client never see it. Rotate and short-expire these tokens — an agent that lingers in a session is an exposure.
- Local-only transport. FastMCP defaults to stdio. If remote, bind HTTP/SSE to
127.0.0.1only and sit behind a reverse proxy with mTLS or a VPN — never a public socket. - Rate and concurrency guards.
RATE_LIMIT_RPScaps each agent's request rate andMAX_SESSIONScaps total pages, so one runaway loop cannot consume the whole worker fleet or your entire spend. Combine with a per-agent spend budget in front of the browser tool, the way you would for any compute.
Retry Rules & Error Handling
| Failure mode | Backoff | Fallback | Escalation |
|---|---|---|---|
| Rate limit (429 from worker or local guard) | Exponential: 200ms base, x2, cap 6s | Queue the action; retry same selector | Alert after 4 consecutive 429s; raise RATE_LIMIT_RPS review |
| HTTP 5xx from the worker | 3 retries: 300ms → 600ms → 1.2s | Re-open the page and re-wait for the target element | Page on-call if the worker stays down |
wait_for timeout |
None — return satisfied=false |
Re-query the snapshot via get_text/click |
Report flaky selector to the agent owner |
| Selector not found | No retry — return ok=false + current a11y_tree | Agent re-reads the a11y tree and retries with the corrected selector | Log selector drift; flag site redesign |
Session limit (MAX_SESSIONS) |
None | close_page then retry, or wait for a slot |
Monitor peak concurrency; scale the worker fleet |
| Network timeout on the worker call | Single retry with the same session id | Fresh open_page re-snapshot |
Check worker region latency |
The production checklist
Before an agent browses production pages: (1) confirm the a11y-tree snapshots are enough for your tasks — forms on heavily dynamic sites may need a raw-DOM escape hatch, so keep one behind a separate, audited tool; (2) enforce privacy mode in the worker, not just the client, because tracker requests cost money and leak data even when nobody reads them; (3) tie every session to a JWT subject and a spend budget, then delete sessions when the job finishes; (4) rehearse the wait_for failure path — flaky selectors are the most common agent failure and the easiest to make gracefully recoverable; (5) benchmark your actual workloads, because the 3-7x resource win is only real if your mix stays within the runtime's compatibility floor.
Kitesurf is the first mainstream sign that the browser is becoming an agent execution environment. Wrapping it in MCP with compact a11y output, session isolation, and rate-limit guards is what turns that runtime into something a production agent can drive safely. For the wider catalogue of servers worth building, see the MCP Directory, and browse Latest AI News for follow-ups on Kitesurf's compatibility roadmap.
Enjoyed this breakdown? Get our morning dispatch in your inbox.
Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.
Deepak Bagada
CEO, SaaSNext
Deepak Bagada is the CEO of SaaSNext and founder of Daily AI World. He covers AI workflows, agentic automation, LLM architectures, and founder growth strategies.
Related Intelligence Analysis
Vercel AI SDK Tool Calling React: 5 Steps (2026)
Vercel AI SDK tool calling React integration is a programming pattern that executes server-side functions based on large language model decisions and streams the results to a React frontend. By combining streamText with...
Fact-Density vs. Word Count: The New SEO for 2026
Fact Density is the ratio of verifiable, unique information to the total word count of a piece of content. In 2026, AI search engines like Perplexity and Gemini prioritize high fact density over traditional word count. A...
NVIDIA Audex vs Qwen3.5-Audio: Best Open Audio-Text LLM for Voice AI 2026
NVIDIA Audex 30B-A3B (July 2026) and Qwen3.5-35B-A3B are the two leading open audio-text LLMs. Audex uniquely handles both audio understanding and generation in a single model while preserving text intelligence. Qwen3.5-...