Skip to main content
Glama

Kitesurf

Kitesurf is a browser MCP that runs entirely inside Cloudflare Worker isolates. It lets ChatGPT load pages, read content, follow links, fill forms, and run page JavaScript through an OAuth-protected /mcp endpoint.

Obscura supplies the DOM, browser APIs, and CSS layout. rquickjs embeds QuickJS-NG in the same Rust WASM module. Rust owns the browser, DOM callbacks, script loading, promises and timers; the Worker supplies networking and platform services. A Durable Object keeps tabs alive across MCP reconnects and serializes actions.

References to Deno.core.ops preserve Obscura's upstream browser bootstrap API, which originally uses deno_core. In this build, a small Deno object inside QuickJS exposes our Rust callbacks and Worker adapters inside private closures. Deno is absent from page globals so frameworks select their browser runtime. Keeping the internal callback names minimizes patches to the pinned upstream source. Neither Deno nor V8 is compiled into the browser WASM module; Obscura's original runtime remains in the submodule but is excluded from this build.

The goal is a small browser with low memory use. Web API and CSS support are incomplete, and there is no pixel rendering or screenshot tool. Site compatibility and memory use are still being tested.

Browser tools

Tool

What it does

browser_tools_profiles

Discover the supported core tools and engine limitations

browser_open

Open about:blank or navigate to an HTTP(S) URL; bare hosts use HTTPS

browser_reload

Reload the selected page and execute its scripts

browser_read

Read the active DOM or fetch a URL as Markdown/readable text; filter sections, get an outline, or discover ancestor llms.txt/llms-full.txt

browser_snapshot

Read a DOM accessibility tree with @e references; scope by selector, limit depth, include URLs, or include noninteractive content

browser_click

Activate a node by reference or CSS selector; optionally open its link in a new tab

browser_fill

Fill an input, textarea or contenteditable element and dispatch input/change events

browser_check, browser_uncheck

Activate checkboxes, radio buttons and ARIA switches when their state needs changing

browser_select

Select one or more options by value or label

browser_eval

Run a JavaScript expression or script and await a returned promise

browser_scroll

Scroll a page or element up, down, left or right by a pixel amount

browser_wait_ms

Process page tasks for a fixed duration

browser_wait_for_selector, browser_wait_for_text

Wait for a CSS-visible element or page text

browser_wait_for_load

Wait for DOMContentLoaded, load, or 500 ms of network inactivity

browser_wait_for_function

Process pending tasks until a synchronous expression is truthy

browser_get_text, browser_get_url, browser_get_title

Read element text, the active URL or the title

browser_status

Inspect script errors, requests, stealth settings, and allocated WASM memory

browser_tab_new, browser_tab_list, browser_tab_switch, browser_tab_close

Manage tabs using stable IDs (t1) or labels

browser_close

Release the session, or all sessions in its namespace with all: true

The surface follows the supported portion of agent-browser's core MCP profile, using browser_ names. It exposes 26 tools: 24 supported core tools plus the existing runtime diagnostics and expression-wait capability. Screenshots, painting, PDF/video output, native keyboard input, back/forward navigation and AI chat are excluded. Chromium launch flags, CLI arguments, filesystem paths, OS certificate configuration and disk-based session restore are not accepted. Unsupported tools and arguments fail validation rather than advertising unavailable behavior.

Snapshots default to interactive elements. Set interactive: false for page structure and text. Pass a returned reference as selector: "@e1"; CSS selectors must identify one element. References survive repeated snapshots of the same elements and are scoped to the latest snapshot in the current tab. Navigation, tab closure and removed elements invalidate them. The tree derives roles and names from the DOM; visibility and geometry have Obscura's layout limitations. Clicks use DOM activation and cannot produce trusted native input events.

All tools accept session, namespace, allowedDomains and timeoutMs. The client-provided params._meta["openai/session"] scopes namespaces and browser sessions to a ChatGPT conversation. Calls with the same conversation, namespace and session keep the selected tab across MCP reconnects. Different conversations have separate tabs and cookies, even when both omit session and namespace. browser_close(all: true) closes only that conversation's namespace. Clients without this metadata share a separate legacy scope; invalid conversation metadata is rejected. This field selects browser state within an authenticated owner's Durable Object; it does not grant access or replace OAuth. OpenAI client metadata reference

Sessions isolate tabs and cookies; tabs within a session share cookies. Every session and tab shares the same 96 MiB maximum WASM heap, and closing the last tab releases it. Navigation replaces the selected DOM and JavaScript VM; it retains cookies but does not persist Web Storage or cross-document history. There is no automatic idle closure. State lives in memory and can be lost when the isolate restarts. Close unused tabs to release their DOM and QuickJS state.

An explicit browser_read URL uses a separate unauthenticated request context, without changing the active tab. HTML extraction uses the existing native parser without bootstrapping page scripts, loading linked resources or adding a parser dependency. Omit the URL to read the rendered DOM and its current authenticated content. raw, requireMd, outline, filter and llms control extraction. llms-full.txt is fetched only when explicitly requested.

Navigation, clicks and scrolling process pending downloads, microtasks and application tasks before returning a small result. They do not automatically build snapshots; call browser_snapshot or browser_read when needed. Repeating intervals and rendering callbacks run without keeping an otherwise idle action open. Delayed activity can be awaited with the wait tools. Calls default to a 120-second timeout; conditional waits default to 25 seconds and URL reads to 30 seconds, bounded by the call timeout. Cancellation aborts networking and releases an interrupted page before the next queued action. There are no application quotas on request count, downloaded bytes, snapshot content or action input size. Downloads queue behind the platform's six concurrent connections. Deployment uses Cloudflare's platform defaults, including the account's subrequest allowance. The Free plan allows 50 subrequests per invocation; redirects also count. A platform resource failure is reported as a failed action, so a page can exceed the account's allowance even when it works locally. Subrequest limits

The entry Worker handles OAuth and MCP transport; browser execution, layout, and page downloads run in BrowserMCP, a SQLite-backed Durable Object. Its default CPU allowance is 30 seconds per invocation, excluding time waiting on network I/O. The Free entry Worker's 10 ms CPU allowance is a separate budget. Durable Object limits, Worker CPU limits

Related MCP server: Cloudflare Playwright MCP

Stealth and memory

Obscura's stealth mode is enabled before page scripts run. The browser uses its JavaScript fingerprint settings, masks browser functions, hides internal bootstrap globals, and blocks tracker domains and their subdomains using Obscura's pinned list. HTTP requests and navigator.userAgent use the same browser profile.

TLS is managed by Workers' fetch. Obscura's native TLS fingerprinting client is not part of this build, so enabling stealth does not reproduce its native network fingerprint.

Cloudflare allows 128 MB per isolate, including the host JavaScript heap and all WASM allocations. Concurrent requests in an isolate share that budget. Cloudflare memory limits

Tabs share a 96 MiB WASM maximum. QuickJS's allocation allowance follows available shared heap capacity with headroom for native browser work. Ordinary DOM calls stay inside WASM and return guest JavaScript values directly. Fetch bodies cross separately from metadata, and geometry callbacks return native objects without JSON encoding and decoding.

Compiled CSS, computed styles and guest geometry objects survive geometry reads. Attribute mutations feed Obscura's retained-style invalidation planner; tree, stylesheet and unsupported changes trigger a fresh cascade. Changes confined to color, opacity or ordinary visibility can preserve geometry when compiled selector dependencies allow it, including shadow styles. Inherited CSS variables use structurally shared maps instead of copying every variable into every component. WASM file size, allocated WASM memory and total isolate RAM are different measurements; reported WASM memory excludes the Worker JavaScript heap.

Run locally

Install nub, Task, fish, OpenSSL, and Rust through rustup. Use the Node version in .node-version and make Cargo available on PATH.

task setup
task check

task setup initializes pinned Git submodules, installs packages with nub, and prepares the pinned Rust/WASM toolchain and matching wasm-bindgen CLI. Repository setup, including the Binaryen optimizer, belongs in this task; CI calls it too.

Generate an owner key once. This command writes the key to an ignored file and prints its SHA-256 hash:

node --input-type=module -e '
import { randomBytes, createHash } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";
const key = randomBytes(32).toString("base64url");
mkdirSync(".secrets", { recursive: true, mode: 0o700 });
writeFileSync(".secrets/owner-access-key", key + "\n", { mode: 0o600, flag: "wx" });
console.log(createHash("sha256").update(key).digest("hex"));
'

Save the printed hash as OWNER_KEY_HASH in the ignored .dev.vars file, then run:

nub run dev

The dev script builds the browser, creates an ignored local HTTPS certificate, and starts Wrangler's local Worker runtime at https://localhost:8787. Keep the owner key for connection approval and recovery.

Deploy and connect

Copy .env.example to the ignored .env. Create a Cloudflare KV namespace for OAuth records and configure these values privately:

Variable

Purpose

CLOUDFLARE_ACCOUNT_ID

Cloudflare account to deploy to

OAUTH_KV_ID

KV namespace for OAuth clients, grants, and tokens

PUBLIC_ORIGIN

Exact public HTTPS origin, without a trailing slash

OWNER_KEY_HASH

Hash of the owner key generated above

CLOUDFLARE_API_TOKEN

Scoped deployment token, supplied through the environment

With the token saved in the ignored .cloudflare-api-token file, deploy from fish:

env CLOUDFLARE_API_TOKEN=(string trim < .cloudflare-api-token) task deploy

task deploy runs checks and builds, requires explicit deployment credentials, and uploads the owner-key hash as a Worker secret. Account IDs, the deployment origin, tokens, and the owner key stay out of tracked configuration.

The deployment contract supports Workers Free by leaving limits unset in wrangler.jsonc. Both validation and deployment reject custom limits: CPU overrides require Workers Paid, and its maximum subrequest allowance is not a Free-plan entitlement. Raising these limits requires revisiting the deployment contract and the account's Workers plan together. Wrangler limits

Connect ChatGPT to /mcp at your configured PUBLIC_ORIGIN, select OAuth, and approve the connection with the owner key. OAuth discovery and client registration are provided by the Worker. The current authorization policy accepts ChatGPT clients and callbacks.

Every MCP request requires the owner's OAuth token and browser:use scope. Authorization uses S256 PKCE, and connection approval and revocation require the owner key. Only its hash is stored in the Worker. The home page can revoke all grants; KV propagation can briefly delay revocation. Keep the same owner-key hash, KV namespace, and Durable Object binding when upgrading an existing deployment.

GitHub Actions runs task setup then task check on pull requests, and task setup then task deploy on pushes to master. Configure the five variables above as repository secrets for deployment. Pull request checks receive no deployment secrets, and deployments are serialized.

Verify

task check runs linting, typechecking, deterministic tests, and task deploy:check. The deployment check validates the platform-defaults contract and dry-runs the actual Wrangler bundle through the deployment script, without credentials or an upload. Wrangler dry runs and local workerd do not enforce account entitlements; the explicit configuration check catches unsupported overrides before deployment. Browser tests cover DOM mutation, scrolling, fetch, cookies, stealth, text encoding, tab lifetime, interruption, and reconnects.

The local chat harness uses the MCP SDK's Streamable HTTP client, the real mcpFetch handler and the compiled WASM browser. Each simulated chat sends params._meta["openai/session"] on every tool call. All chats share one browser instance, matching one authenticated owner's Durable Object. MCP HTTP requests are handled in process; the CLI uses real network fetches for page URLs.

task test:mcp
task mcp:harness < tests/fixtures/mcp-session.jsonl

The offline example interleaves two chats, scrolls chat a, reconnects its MCP client and checks that the count is still 20 and scroll position is still 300. Clicking its original button brings it into view and increments the count. The final results are {"count":"21","y":0} for a and {"count":null,"y":0} for b. Run task mcp:harness to enter your own JSONL commands, or redirect a file to it. Use method: "tools/list" to discover tools, "tools/call" with name and arguments to call one, and "reconnect" to replace a chat's MCP client. The chat string becomes openai/session; it is not injected into tool arguments. Results are JSONL on stdout; any failed command makes the process exit nonzero after consuming the input. Add -- --trace to print HTTP requests and response statuses to stderr. EOF closes clients and releases all browser state.

The regression tests exercise real DOM mutation, snapshot references, scroll position, cookies, concurrent calls, scoped closure and loss of state after recreating the browser. They use fixture page responses and require no network or credentials. This harness tests the documented conversation metadata contract; it does not reproduce ChatGPT's model decisions, OAuth, undocumented retry behavior, or Cloudflare's Durable Object lifecycle. Exact ChatGPT behavior still requires a live ChatGPT test.

For the live acceptance test, leave nub run dev running and use a second terminal:

task test:yandex

The test exercises OAuth, MCP reconnects, and creating and closing tabs, then loads the Yandex vacancies page. It requires 20 openings before scrolling and 21 afterward, with no pagination request before scrolling. The page's own JavaScript must fetch the next cursor and append one distinct opening while retaining the original 20. The page must contain all 21 openings when scrolling finishes, without another wait call. Scrolling advances in 576-pixel steps so intersection observers can see the pagination sentinel. To verify the button instead, run node scripts/verify.ts --yandex --click with the local certificate configured through NODE_EXTRA_CA_CERTS.

The test is opt-in because the live site and its vacancy count can change. Successful runs write .wrangler/yandex-result.json; cleanup closes the browser and revokes the test grant. Leave a minute between full runs to respect the authentication rate limit.

task test:sites also checks live readable content and selectors on Hacker News, Wikipedia and MDN, writing .wrangler/sites-result.json. These are limited smoke tests; they passed on September 10, 2026 but do not verify every page feature or broad browser compatibility. MDN still reports dynamic-module loading errors.

Dependencies and builds

Cargo supplies Obscura's DOM and its Taffy fork at pinned Git revisions. Other direct crate versions are exact, and native/Cargo.lock locks transitive dependencies. Rust is pinned in rust-toolchain.toml; JavaScript dependencies use nub.lock.

The pinned vendor/obscura submodule supplies upstream code and data that need direct integration: browser API bootstrap, document-write parser, layout sources, and tracker list. Local Rust and TypeScript adapters connect those pieces to WASM and Workers. Upstream source stays in the dependency or submodule; generated bindings, adapted build output, and WASM files stay in ignored build directories.

task browser:build builds the browser assets. task build also bundles the authorization client. Build and test tasks declare their prerequisites in Taskfile.yaml.

MIT; see LICENSE. Upstream dependencies retain their own licenses.

Related MCP Connectors

Related MCP Servers