MCP 2 in Owl Browser: Stateless Protocol, Composite Tools, and Telling the Truth About What Is Missing

The Model Context Protocol changed shape in its 2026-07-28 revision, and the change is bigger than a version bump. Sessions are gone. The initialize handshake is gone. Every request now carries its own protocol version, client identity and capabilities, and every result has to say what kind of result it is. We have shipped that revision in Owl Browser 1.3.2, alongside two new tools built specifically for how agents actually spend their calls.
This post covers what MCP 2 changes, why the stateless model suits a browser unusually well, and what the two new tools do.
What MCP 2 actually changes
The headline is subtraction. The 2026-07-28 revision removes protocol level sessions and the Mcp-Session-Id header, removes the initialize and notifications/initialized handshake, removes ping and logging/setLevel, and removes SSE stream resumability. Sampling, roots and logging are deprecated. What replaces the handshake is per-request metadata: each call carries io.modelcontextprotocol/protocolVersion, clientInfo and clientCapabilities, and each result carries the server identity back.
The part that matters most for a browser is SEP-2567. Cross-call state is now specified as opaque server-minted handles passed as ordinary tool arguments. That is exactly how Owl has always worked. You call browser_create_context, you get a context_id back, and you pass it to every subsequent call. The protocol caught up to the design rather than the other way round, so adopting the revision meant removing code rather than adding it. The DELETE endpoint that acknowledged session termination without doing anything, and the GET stream that emitted keepalives nobody read, both simply went away.
Both eras on one endpoint
Nothing that works today stops working. A single POST /mcp endpoint serves both eras and decides per request which one it is looking at. A request carrying modern metadata is served statelessly under the new revision. An initialize request selects the older handshake path and is answered exactly as before, byte for byte, with no resultType field and no cache hints added to its results.
The server advertises five protocol revisions and negotiates down cleanly:
{
"supportedVersions": [
"2026-07-28",
"2025-11-25",
"2025-06-18",
"2025-03-26",
"2024-11-05"
],
"capabilities": {
"tools": {},
"resources": {},
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}Ask for a version we do not serve and you get an UnsupportedProtocolVersionError listing what is available, so a client can retry rather than guess. server/discover is implemented as the specification requires, and answers in both eras so a dual-era client can use it as a cheap probe.
Headers that let a proxy route without reading the body
MCP 2 requires the Mcp-Method header on every POST and Mcp-Name on the calls that name something. It also adds x-mcp-header, which lets a server nominate a tool parameter to be mirrored into an HTTP header. Owl nominates context_id:
POST /mcp HTTP/1.1
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: browser_observe
Mcp-Param-Context-Id: ctx_019fd4add4a7
{ "jsonrpc": "2.0", "id": 4, "method": "tools/call",
"params": { "name": "browser_observe",
"arguments": { "context_id": "ctx_019fd4add4a7" } } }context_id is the routing key for a multi-process browser pool. Surfacing it as a header means a load balancer can send the call to the process that owns that context without parsing the JSON-RPC body. The server validates every mirrored header against the body and rejects a mismatch with a HeaderMismatch error, so a proxy routing on the header and the server acting on the body can never disagree.
browser_do: one call instead of six
We tested this release by having thirty agents drive real websites, from strong models down to small ones, and reading where their calls went. The pattern was consistent. A large share of every session was spent on sequences that were fully specified in advance: type, type, type, click. Each step cost a network round trip, and each returned a full page view the agent did not need until the end.
browser_do takes the whole sequence:
{
"context_id": "ctx_019fd4add4a7",
"steps": [
{ "action": "navigate", "url": "https://example.com/login" },
{ "action": "type", "selector": "#username", "text": "ada" },
{ "action": "type", "selector": "#password", "text": "hunter2" },
{ "action": "click", "selector": "button[type=submit]" }
]
}Each step is executed and verified server side. Execution stops at the first failure unless you ask it to continue. The reply is a short per-step log plus one final page view, not one view per step:
browser_do completed all 4 steps.
step 1 (navigate): ok
step 2 (type): ok
step 3 (type): ok
step 4 (click): ok
[page after these steps - now at https://example.com/secure - handles from
before are no longer valid; use the tokens below]When the last step can navigate, the server settles on the network idle event before capturing that view, so a login click returns the page you landed on rather than the form you submitted. selector accepts a handle token from browser_observe, a CSS selector, XxY coordinates, or a plain English description resolved by the semantic matcher. No language model is involved at any point, which matters because the default browser does not ship one.
browser_rows: lists as data
The other thing agents asked for, unprompted and repeatedly, was a way to read a list without reconstructing it from prose. A search results page is the most common shape on the commercial web and the hardest to read reliably, because the thing you need is the difference between rows and the thing a compact render wants to do is collapse what rows have in common.
browser_rows returns the repeated rows as data:
{
"groups": [
{ "template": "T3", "rows": [
{ "token": "l119", "fields": ["Sharp Objects", "£47.82", "In stock"] },
{ "token": "l132", "fields": ["In a Dark, Dark Wood", "£19.63", "In stock"] },
{ "token": "l145", "fields": ["The Past Never Ends", "£56.50", "In stock"] }
]}
],
"row_count": 3
}Each row carries a handle token you can pass straight to browser_click as a selector. Main page content is read before site chrome, so a category sidebar cannot crowd out the product grid. Where the underlying render had to shorten a field, the row is flagged clipped rather than handed back looking complete.
Saying what was left out
The most valuable thing we changed in this release is not a feature. Running thirty agents against real sites made one failure mode stand out above all others, and it was not crashes or error messages. It was this sequence: the page render quietly omits something, the page still reports itself ready, and the model fills the gap with a plausible nearby value. The result is an answer that looks completely correct and is wrong.
We found and verified several instances against ground truth. A ranked list folded a row to the shape rank, upvote, score, downvote, title, two unlabelled adjacent numbers, and an agent reported a 451 point post as having 3 points. A package page hid its labelled download figure inside a region that expanded only partially, so an agent read a stale point off a chart and reported 46.7 million weekly downloads where the real figure was 130 million.
Every fix took the same form. Make the omission announce itself, and name the way to recover it.
- Ranked lists now render as rank=2 score=19 rather than two bare numbers, and a hidden score reads score=hidden rather than a bullet.
- A collapsed group states how to reopen it: re-observe with detail='full' or browser_expand.
- Content pruned below the fold says so in words, with the count and the instruction to scroll and observe again.
- Shortened field text is flagged rather than returned as if complete.
- Failures are reported as failures. A bot protection wall, a lost context or a dead element handle used to arrive inside a successful envelope.
That last point deserves emphasis. Owl now derives success from the whole response rather than a single top level flag, and a failed call leads with one sentence saying what to do next, for example that the context no longer exists and a new one is needed before navigating again. Where a block signal is only a heuristic, the message says so and tells the agent to wait and re-observe before concluding the site is blocked, because that signal fires on pages that were merely still loading.
The rest of 1.3.2
| Area | What changed |
|---|---|
| Browser core | Chromium 151 (CEF 7922), profile pinning across Chrome 143 to 151 |
| Transport | Streamable HTTP at POST /mcp, dual era, GET and DELETE now return 405 |
| Extensions | io.modelcontextprotocol/tasks for long running work |
| Resources | owl:// store so recordings, downloads and profile exports return as links |
| Auth | RFC 8707 audience binding, RFC 9207 iss, application_type on registration |
| Screenshots | Fixed a stale frame cache that could return the previous page |
Getting it
MCP is served directly from the Owl HTTP server. Point a client at the endpoint with a bearer token or complete the OAuth flow, and select how much of the tool surface to expose with OWL_MCP_PROFILE, which ranges from a 16 tool agent surface to the full set.
# agent profile: the observe and act loop plus the composites
OWL_MCP_PROFILE=agent ./owl_http_server
# point any MCP 2 client at it
curl -X POST http://localhost:8080/mcp \
-H "Authorization: Bearer $OWL_HTTP_TOKEN" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: server/discover" \
-d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}'If you are running an older client, you do not need to do anything. The same endpoint answers the initialize handshake exactly as it did before.
Want to automate seamlessly?
Owl Browser bypasses all sophisticated bot detections effortlessly.