Examples & benchmarks / #79 · 2026-10-31 · Example · Easy · for ai agents, llm data
MCP in action: an MCP client drives a cloud browser through cdpfleet.com/mcp
A script connects to the hosted MCP server with its API key and does what an AI assistant does: list the tools, launch a browser, open a page, read it as an accessibility snapshot, extract data and close the browser — once in one MCP session, and once with a new MCP session for every call, as ChatGPT does. In five languages, four of them on the official MCP SDK.
Run on production on 2026-10-31: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
Claude, ChatGPT, Gemini and Cursor reach cdpfleet through its hosted MCP server, https://cdpfleet.com/mcp. What they actually do there is a handful of JSON-RPC calls: initialize, list the tools, call them one by one. Doing the same from a script shows what each call costs, what comes back, and what happens to the browser when the client opens a new MCP session for every call — which is how ChatGPT talks to MCP servers.
What we used, and why
| What | Why |
|---|---|
https://cdpfleet.com/mcp | The hosted MCP server, over Streamable HTTP. Assistants sign in with OAuth; a script sends its API key as Authorization: Bearer <key>. |
| The official MCP SDKs | TypeScript @modelcontextprotocol/sdk 1.32.2, Python mcp 2.3.0, Java io.modelcontextprotocol.sdk:mcp 2.1.0 and C# ModelContextProtocol 2.3.0. The Go program speaks the protocol with the standard library (JSON-RPC 2.0 over HTTP), because the official Go SDK needs Go 1.23 or newer. |
launch_browser | Headless Chromium through your own proxy (proxy), without a recording (record: false). One browser at a time per API key. |
navigate, snapshot, evaluate | Open the catalogue, read it as an accessibility snapshot (the whole page and just the product list), and run an extraction function in the page. |
close_browser, account | End the browser session, then check the plan's live sessions. |
| A new MCP session per call | The second run initializes a fresh MCP session for each tool call and drops it without a DELETE, the way ChatGPT does. |
How it works
- Connect: initialize an MCP session with the API key as bearer token and list the tools.
- Launch headless Chromium, open books.toscrape.com, take an accessibility snapshot of the page and of its product list, read the HTML length, and extract every book with
evaluate. - Close the browser, try one more
navigate, and readaccount; end the MCP session. - Then do it again with a new MCP session for every call: launch, open page 2, read where the browser is, close.
The code
The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:
CDPFLEET_API_KEY— your API key (dashboard → API keys)PROXY_URL— your proxy, e.g.http://user:[email protected]:8000
// npm install @modelcontextprotocol/[email protected]
// env: CDPFLEET_API_KEY, PROXY_URL
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
const MCP = new URL('https://cdpfleet.com/mcp');
const KEY = process.env.CDPFLEET_API_KEY;
const PROXY = process.env.PROXY_URL;
const EXTRACT = "() => [...document.querySelectorAll('article.product_pod')].map((a) => ({ title: a.querySelector('h3 a').title, price: a.querySelector('.price_color').textContent }))";
// A script authenticates with its API key as a bearer token (assistants use OAuth instead).
async function connect() {
const transport = new StreamableHTTPClientTransport(MCP, { requestInit: { headers: { authorization: `Bearer ${KEY}` } } });
const client = new Client({ name: 'cdpfleet-case-79', version: '1.0.0' });
await client.connect(transport); // initialize + initialized
return { client, transport };
}
// One tool call, timed; the text of its result.
async function call(client, log, name, args = {}) {
const t = Date.now();
const r = await client.callTool({ name, arguments: args });
const text = r.content.filter((c) => c.type === 'text').map((c) => c.text).join('\n');
log.push({ tool: name, ms: Date.now() - t, error: Boolean(r.isError) });
return { text, error: Boolean(r.isError) };
}
// A shared proxy drops the odd connection: one retry of a failed page load.
async function navigate(client, log, url) {
const r = await call(client, log, 'navigate', { url });
return r.error ? call(client, log, 'navigate', { url }) : r;
}
// ---- 1. One MCP session for the whole job, as Claude or Cursor keep one.
async function oneSession() {
const log = [];
let t = Date.now();
const { client, transport } = await connect();
log.push({ tool: 'initialize', ms: Date.now() - t, error: false });
t = Date.now();
const { tools } = await client.listTools();
log.push({ tool: 'tools/list', ms: Date.now() - t, error: false });
const out = { mcp_session: Boolean(transport.sessionId), tools: tools.map((x) => x.name).sort() };
const launched = await call(client, log, 'launch_browser', { browser: 'chromium', headless: true, proxy: PROXY, record: false });
// "Launched chromium 148.0.7778.5 (linux_headless, 1 thread(s)), session <id>. …"
const m = /^Launched (\S+) (\S+) \((\w+), (\d+) thread/.exec(launched.text);
Object.assign(out, { browser: m?.[1], version: m?.[2], resource_class: m?.[3], weight: Number(m?.[4]) });
out.session_id = /session ([0-9a-f-]{36})/.exec(launched.text)?.[1] ?? null;
out.navigate = (await navigate(client, log, 'https://books.toscrape.com/')).text.split('\n')[0];
out.snapshot_chars = (await call(client, log, 'snapshot')).text.length;
out.snapshot_list_chars = (await call(client, log, 'snapshot', { selector: 'ol.row' })).text.length;
out.html_chars = Number((await call(client, log, 'evaluate', { expression: 'document.documentElement.outerHTML.length' })).text);
const books = JSON.parse((await call(client, log, 'evaluate', { expression: EXTRACT })).text);
out.books = books.length;
out.first_book = books[0];
out.closed = (await call(client, log, 'close_browser')).text.startsWith(`Session ${out.session_id} ended`);
const after = await call(client, log, 'navigate', { url: 'https://books.toscrape.com/' });
out.after_close = { error: after.error, text: after.text.split('\n')[0] };
const account = JSON.parse((await call(client, log, 'account')).text);
out.live_sessions_after = account.live_sessions;
await transport.terminateSession(); // DELETE: ends the MCP session
await client.close();
return { ...out, calls: log };
}
// ---- 2. A new MCP session for every tool call, as ChatGPT does.
async function perCallSessions() {
const log = [];
const sessions = [];
async function once(name, args) {
const t = Date.now();
const { client, transport } = await connect();
const r = name === 'navigate' ? await navigate(client, log, args.url) : await call(client, log, name, args);
log[log.length - 1].with_initialize_ms = Date.now() - t;
sessions.push(transport.sessionId);
await client.close(); // no DELETE: the client just goes away
return r;
}
const launched = await once('launch_browser', { browser: 'chromium', headless: true, proxy: PROXY, record: false });
const sessionId = /session ([0-9a-f-]{36})/.exec(launched.text)?.[1] ?? null;
const nav = await once('navigate', { url: 'https://books.toscrape.com/catalogue/page-2.html' });
const where = await once('evaluate', { expression: '({ url: location.href, books: document.querySelectorAll("article.product_pod").length })' });
const closed = await once('close_browser', {});
return {
mcp_sessions: sessions.length,
distinct_mcp_sessions: new Set(sessions).size,
browser_session_id: sessionId,
navigate: nav.text.split('\n')[0],
later_session_sees: JSON.parse(where.text),
closed_same_browser: closed.text.startsWith(`Session ${sessionId} ended`),
calls: log,
};
}
const one = await oneSession();
const perCall = await perCallSessions();
console.log(JSON.stringify({ language: 'node', one_session: one, per_call: perCall, measured_at: new Date().toISOString().slice(0, 10) }, null, 2));
# pip install mcp==2.3.0
# env: CDPFLEET_API_KEY, PROXY_URL
import asyncio
import json
import os
import re
import time
from mcp import ClientSession
from mcp.client.streamable_http import create_mcp_http_client, streamable_http_client
MCP = "https://cdpfleet.com/mcp"
KEY = os.environ["CDPFLEET_API_KEY"]
PROXY = os.environ["PROXY_URL"]
EXTRACT = ("() => [...document.querySelectorAll('article.product_pod')].map((a) => ({ title: a.querySelector('h3 a').title,"
" price: a.querySelector('.price_color').textContent }))")
def ms(t):
return round((time.monotonic() - t) * 1000)
class Connection:
"""One MCP session over Streamable HTTP; a script authenticates with its API key as a bearer token."""
def __init__(self, terminate):
self.terminate = terminate
self.session_id = None
async def __aenter__(self):
http = create_mcp_http_client(headers={"authorization": f"Bearer {KEY}"})
async def remember(response): # the server's MCP session id, from its response header
self.session_id = response.headers.get("mcp-session-id") or self.session_id
http.event_hooks["response"].append(remember)
self._http = http
self._streams = streamable_http_client(MCP, http_client=http, terminate_on_close=self.terminate)
read, write = await self._streams.__aenter__()
self._session = ClientSession(read, write)
self.client = await self._session.__aenter__()
await self.client.initialize()
return self
async def __aexit__(self, *exc):
await self._session.__aexit__(*exc)
await self._streams.__aexit__(*exc)
await self._http.aclose()
async def call(client, log, name, args=None):
t = time.monotonic()
r = await client.call_tool(name, args or {})
text = "\n".join(c.text for c in r.content if c.type == "text")
log.append({"tool": name, "ms": ms(t), "error": bool(r.is_error)})
return text, bool(r.is_error)
async def navigate(client, log, url):
"""A shared proxy drops the odd connection: one retry of a failed page load."""
text, err = await call(client, log, "navigate", {"url": url})
return await call(client, log, "navigate", {"url": url}) if err else (text, err)
# ---- 1. One MCP session for the whole job, as Claude or Cursor keep one.
async def one_session():
log, out = [], {}
t = time.monotonic()
async with Connection(terminate=True) as c:
log.append({"tool": "initialize", "ms": ms(t), "error": False})
t = time.monotonic()
tools = (await c.client.list_tools()).tools
log.append({"tool": "tools/list", "ms": ms(t), "error": False})
out["mcp_session"] = c.session_id is not None
out["tools"] = sorted(x.name for x in tools)
text, _ = await call(c.client, log, "launch_browser",
{"browser": "chromium", "headless": True, "proxy": PROXY, "record": False})
m = re.match(r"^Launched (\S+) (\S+) \((\w+), (\d+) thread", text)
out.update(browser=m and m[1], version=m and m[2], resource_class=m and m[3], weight=int(m[4]) if m else None)
sid = re.search(r"session ([0-9a-f-]{36})", text)
out["session_id"] = sid[1] if sid else None
out["navigate"] = (await navigate(c.client, log, "https://books.toscrape.com/"))[0].split("\n")[0]
out["snapshot_chars"] = len((await call(c.client, log, "snapshot"))[0])
out["snapshot_list_chars"] = len((await call(c.client, log, "snapshot", {"selector": "ol.row"}))[0])
out["html_chars"] = int((await call(c.client, log, "evaluate", {"expression": "document.documentElement.outerHTML.length"}))[0])
books = json.loads((await call(c.client, log, "evaluate", {"expression": EXTRACT}))[0])
out["books"] = len(books)
out["first_book"] = books[0]
closed, _ = await call(c.client, log, "close_browser")
out["closed"] = closed.startswith(f"Session {out['session_id']} ended")
text, err = await call(c.client, log, "navigate", {"url": "https://books.toscrape.com/"})
out["after_close"] = {"error": err, "text": text.split("\n")[0]}
out["live_sessions_after"] = json.loads((await call(c.client, log, "account"))[0])["live_sessions"]
out["calls"] = log
return out
# ---- 2. A new MCP session for every tool call, as ChatGPT does.
async def per_call_sessions():
log, sessions = [], []
async def once(name, args):
t = time.monotonic()
async with Connection(terminate=False) as c: # no DELETE: the client just goes away
text, err = await (navigate(c.client, log, args["url"]) if name == "navigate" else call(c.client, log, name, args))
sessions.append(c.session_id)
log[-1]["with_initialize_ms"] = ms(t)
return text
launched = await once("launch_browser", {"browser": "chromium", "headless": True, "proxy": PROXY, "record": False})
sid = re.search(r"session ([0-9a-f-]{36})", launched)
browser_session = sid[1] if sid else None
nav = await once("navigate", {"url": "https://books.toscrape.com/catalogue/page-2.html"})
where = await once("evaluate", {"expression": '({ url: location.href, books: document.querySelectorAll("article.product_pod").length })'})
closed = await once("close_browser", {})
return {
"mcp_sessions": len(sessions),
"distinct_mcp_sessions": len(set(sessions)),
"browser_session_id": browser_session,
"navigate": nav.split("\n")[0],
"later_session_sees": json.loads(where),
"closed_same_browser": closed.startswith(f"Session {browser_session} ended"),
"calls": log,
}
async def main():
one = await one_session()
per_call = await per_call_sessions()
print(json.dumps({"language": "python", "one_session": one, "per_call": per_call,
"measured_at": time.strftime("%Y-%m-%d", time.gmtime())}, indent=2, ensure_ascii=False))
asyncio.run(main())
// Maven: io.modelcontextprotocol.sdk:mcp:2.1.0, com.google.code.gson:gson:2.11.0
// env: CDPFLEET_API_KEY, PROXY_URL
import com.google.gson.*;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema;
import java.time.Duration;
import java.util.*;
import java.util.regex.*;
import java.util.stream.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final String PROXY = System.getenv("PROXY_URL");
static final String EXTRACT = "() => [...document.querySelectorAll('article.product_pod')].map((a) => ({ title: a.querySelector('h3 a').title, price: a.querySelector('.price_color').textContent }))";
static final Pattern SESSION = Pattern.compile("session ([0-9a-f-]{36})");
// One MCP session over Streamable HTTP; a script authenticates with its API key as a bearer token.
// `seen` collects the MCP session ids the client sends (the transport adds the header itself).
static McpSyncClient connect(Set<String> seen) {
var transport = HttpClientStreamableHttpTransport.builder("https://cdpfleet.com")
.endpoint("/mcp")
.httpRequestCustomizer((builder, method, uri, body, ctx) -> {
builder.header("Authorization", "Bearer " + KEY);
builder.copy().build().headers().firstValue("mcp-session-id").ifPresent(seen::add);
})
.build();
var client = McpClient.sync(transport).requestTimeout(Duration.ofSeconds(120)).build();
client.initialize();
return client;
}
record Result(String text, boolean error) {}
static Result call(McpSyncClient client, List<Map<String, Object>> log, String name, Map<String, Object> args) {
long t = System.currentTimeMillis();
var r = client.callTool(new McpSchema.CallToolRequest(name, args));
String text = r.content().stream().filter(c -> c instanceof McpSchema.TextContent)
.map(c -> ((McpSchema.TextContent) c).text()).collect(Collectors.joining("\n"));
boolean error = Boolean.TRUE.equals(r.isError());
var entry = new LinkedHashMap<String, Object>();
entry.put("tool", name); entry.put("ms", System.currentTimeMillis() - t); entry.put("error", error);
log.add(entry);
return new Result(text, error);
}
// A shared proxy drops the odd connection: one retry of a failed page load.
static Result navigate(McpSyncClient client, List<Map<String, Object>> log, String url) {
var r = call(client, log, "navigate", Map.of("url", url));
return r.error() ? call(client, log, "navigate", Map.of("url", url)) : r;
}
static String firstLine(String s) { return s.split("\n", 2)[0]; }
static Map<String, Object> launchArgs() { return Map.of("browser", "chromium", "headless", true, "proxy", PROXY, "record", false); }
static Map<String, Object> entry(String tool, long ms) { var e = new LinkedHashMap<String, Object>(); e.put("tool", tool); e.put("ms", ms); e.put("error", false); return e; }
// ---- 1. One MCP session for the whole job, as Claude or Cursor keep one.
static Map<String, Object> oneSession() {
var log = new ArrayList<Map<String, Object>>();
var out = new LinkedHashMap<String, Object>();
var seen = new HashSet<String>();
long t = System.currentTimeMillis();
var client = connect(seen);
log.add(entry("initialize", System.currentTimeMillis() - t));
t = System.currentTimeMillis();
var tools = client.listTools().tools();
log.add(entry("tools/list", System.currentTimeMillis() - t));
out.put("mcp_session", !seen.isEmpty());
out.put("tools", tools.stream().map(McpSchema.Tool::name).sorted().toList());
var launched = call(client, log, "launch_browser", launchArgs()).text();
var m = Pattern.compile("^Launched (\\S+) (\\S+) \\((\\w+), (\\d+) thread").matcher(launched);
if (m.find()) { out.put("browser", m.group(1)); out.put("version", m.group(2)); out.put("resource_class", m.group(3)); out.put("weight", Integer.parseInt(m.group(4))); }
var sm = SESSION.matcher(launched);
String sid = sm.find() ? sm.group(1) : "";
out.put("session_id", sid);
out.put("navigate", firstLine(navigate(client, log, "https://books.toscrape.com/").text()));
out.put("snapshot_chars", call(client, log, "snapshot", Map.of()).text().length());
out.put("snapshot_list_chars", call(client, log, "snapshot", Map.of("selector", "ol.row")).text().length());
out.put("html_chars", Integer.parseInt(call(client, log, "evaluate", Map.of("expression", "document.documentElement.outerHTML.length")).text().trim()));
var books = JsonParser.parseString(call(client, log, "evaluate", Map.of("expression", EXTRACT)).text()).getAsJsonArray();
out.put("books", books.size());
out.put("first_book", Map.of("title", books.get(0).getAsJsonObject().get("title").getAsString(), "price", books.get(0).getAsJsonObject().get("price").getAsString()));
out.put("closed", call(client, log, "close_browser", Map.of()).text().startsWith("Session " + sid + " ended"));
var after = call(client, log, "navigate", Map.of("url", "https://books.toscrape.com/"));
out.put("after_close", Map.of("error", after.error(), "text", firstLine(after.text())));
var account = JsonParser.parseString(call(client, log, "account", Map.of()).text()).getAsJsonObject();
out.put("live_sessions_after", account.get("live_sessions").getAsInt());
client.closeGracefully(); // ends the MCP session (DELETE)
out.put("calls", log);
return out;
}
// ---- 2. A new MCP session for every tool call, as ChatGPT does.
static Map<String, Object> perCallSessions() {
var log = new ArrayList<Map<String, Object>>();
var sessions = new ArrayList<String>();
java.util.function.BiFunction<String, Map<String, Object>, String> once = (name, args) -> {
long t = System.currentTimeMillis();
var seen = new HashSet<String>();
var client = connect(seen);
var text = (name.equals("navigate") ? navigate(client, log, (String) args.get("url")) : call(client, log, name, args)).text();
sessions.add(seen.isEmpty() ? null : seen.iterator().next());
client.close(); // no graceful shutdown: the client just goes away
log.get(log.size() - 1).put("with_initialize_ms", System.currentTimeMillis() - t);
return text;
};
var launched = once.apply("launch_browser", launchArgs());
var sm = SESSION.matcher(launched);
String sid = sm.find() ? sm.group(1) : "";
var nav = once.apply("navigate", Map.of("url", "https://books.toscrape.com/catalogue/page-2.html"));
var where = once.apply("evaluate", Map.of("expression", "({ url: location.href, books: document.querySelectorAll(\"article.product_pod\").length })"));
var closed = once.apply("close_browser", Map.of());
var sees = JsonParser.parseString(where).getAsJsonObject();
var out = new LinkedHashMap<String, Object>();
out.put("mcp_sessions", sessions.size());
out.put("distinct_mcp_sessions", sessions.stream().filter(Objects::nonNull).distinct().count());
out.put("browser_session_id", sid);
out.put("navigate", firstLine(nav));
out.put("later_session_sees", Map.of("url", sees.get("url").getAsString(), "books", sees.get("books").getAsInt()));
out.put("closed_same_browser", closed.startsWith("Session " + sid + " ended"));
out.put("calls", log);
return out;
}
public static void main(String[] args) {
var one = oneSession();
var perCall = perCallSessions();
var result = new LinkedHashMap<String, Object>();
result.put("language", "java");
result.put("one_session", one);
result.put("per_call", perCall);
result.put("measured_at", java.time.LocalDate.now(java.time.ZoneOffset.UTC).toString());
System.out.println(new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create().toJson(result));
System.exit(0); // the SDK's HTTP client keeps non-daemon threads
}
}
// dotnet add package ModelContextProtocol --version 2.3.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Diagnostics;
using System.Text.Json;
using System.Text.RegularExpressions;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
var mcp = new Uri("https://cdpfleet.com/mcp");
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
var proxy = Environment.GetEnvironmentVariable("PROXY_URL")!;
const string Extract = "() => [...document.querySelectorAll('article.product_pod')].map((a) => ({ title: a.querySelector('h3 a').title, price: a.querySelector('.price_color').textContent }))";
// One MCP session over Streamable HTTP; a script authenticates with its API key as a bearer token.
// ownsSession: end the MCP session (DELETE) when the client is disposed.
Task<McpClient> Connect(bool ownsSession) => McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = mcp,
TransportMode = HttpTransportMode.StreamableHttp,
AdditionalHeaders = new Dictionary<string, string> { ["Authorization"] = $"Bearer {key}" },
OwnsSession = ownsSession,
}));
async Task<(string Text, bool Error)> Call(McpClient client, List<Dictionary<string, object?>> log, string name, Dictionary<string, object?>? args = null)
{
var sw = Stopwatch.StartNew();
var r = await client.CallToolAsync(name, args ?? new Dictionary<string, object?>());
var text = string.Join("\n", r.Content.OfType<TextContentBlock>().Select(c => c.Text));
log.Add(new() { ["tool"] = name, ["ms"] = sw.ElapsedMilliseconds, ["error"] = r.IsError == true });
return (text, r.IsError == true);
}
// A shared proxy drops the odd connection: one retry of a failed page load.
async Task<(string Text, bool Error)> Navigate(McpClient client, List<Dictionary<string, object?>> log, string url)
{
var r = await Call(client, log, "navigate", new() { ["url"] = url });
return r.Error ? await Call(client, log, "navigate", new() { ["url"] = url }) : r;
}
static string FirstLine(string s) => s.Split('\n')[0];
var launchArgs = new Dictionary<string, object?> { ["browser"] = "chromium", ["headless"] = true, ["proxy"] = proxy, ["record"] = false };
var sessionRe = new Regex("session ([0-9a-f-]{36})");
// ---- 1. One MCP session for the whole job, as Claude or Cursor keep one.
async Task<Dictionary<string, object?>> OneSession()
{
var log = new List<Dictionary<string, object?>>();
var o = new Dictionary<string, object?>();
var sw = Stopwatch.StartNew();
await using (var client = await Connect(ownsSession: true))
{
log.Add(new() { ["tool"] = "initialize", ["ms"] = sw.ElapsedMilliseconds, ["error"] = false });
sw.Restart();
var tools = await client.ListToolsAsync();
log.Add(new() { ["tool"] = "tools/list", ["ms"] = sw.ElapsedMilliseconds, ["error"] = false });
o["mcp_session"] = client.SessionId != null;
o["tools"] = tools.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal).ToList();
var (launched, _) = await Call(client, log, "launch_browser", launchArgs);
var m = Regex.Match(launched, @"^Launched (\S+) (\S+) \((\w+), (\d+) thread");
if (m.Success) { o["browser"] = m.Groups[1].Value; o["version"] = m.Groups[2].Value; o["resource_class"] = m.Groups[3].Value; o["weight"] = int.Parse(m.Groups[4].Value); }
var sid = sessionRe.Match(launched).Groups[1].Value;
o["session_id"] = sid;
o["navigate"] = FirstLine((await Navigate(client, log, "https://books.toscrape.com/")).Text);
o["snapshot_chars"] = (await Call(client, log, "snapshot")).Text.Length;
o["snapshot_list_chars"] = (await Call(client, log, "snapshot", new() { ["selector"] = "ol.row" })).Text.Length;
o["html_chars"] = int.Parse((await Call(client, log, "evaluate", new() { ["expression"] = "document.documentElement.outerHTML.length" })).Text);
var books = JsonDocument.Parse((await Call(client, log, "evaluate", new() { ["expression"] = Extract })).Text).RootElement;
o["books"] = books.GetArrayLength();
o["first_book"] = new Dictionary<string, string?> { ["title"] = books[0].GetProperty("title").GetString(), ["price"] = books[0].GetProperty("price").GetString() };
o["closed"] = (await Call(client, log, "close_browser")).Text.StartsWith($"Session {sid} ended");
var after = await Call(client, log, "navigate", new() { ["url"] = "https://books.toscrape.com/" });
o["after_close"] = new Dictionary<string, object?> { ["error"] = after.Error, ["text"] = FirstLine(after.Text) };
var account = JsonDocument.Parse((await Call(client, log, "account")).Text).RootElement;
o["live_sessions_after"] = account.GetProperty("live_sessions").GetInt32();
}
o["calls"] = log;
return o;
}
// ---- 2. A new MCP session for every tool call, as ChatGPT does.
async Task<Dictionary<string, object?>> PerCallSessions()
{
var log = new List<Dictionary<string, object?>>();
var sessions = new List<string?>();
async Task<string> Once(string name, Dictionary<string, object?> args)
{
var sw = Stopwatch.StartNew();
string text;
await using (var client = await Connect(ownsSession: false)) // no DELETE: the client just goes away
{
text = (name == "navigate" ? await Navigate(client, log, (string)args["url"]!) : await Call(client, log, name, args)).Text;
sessions.Add(client.SessionId);
}
log[^1]["with_initialize_ms"] = sw.ElapsedMilliseconds;
return text;
}
var launched = await Once("launch_browser", launchArgs);
var sid = sessionRe.Match(launched).Groups[1].Value;
var nav = await Once("navigate", new() { ["url"] = "https://books.toscrape.com/catalogue/page-2.html" });
var where = await Once("evaluate", new() { ["expression"] = "({ url: location.href, books: document.querySelectorAll(\"article.product_pod\").length })" });
var closed = await Once("close_browser", new());
return new()
{
["mcp_sessions"] = sessions.Count,
["distinct_mcp_sessions"] = sessions.Distinct().Count(),
["browser_session_id"] = sid,
["navigate"] = FirstLine(nav),
["later_session_sees"] = JsonSerializer.Deserialize<Dictionary<string, object>>(where),
["closed_same_browser"] = closed.StartsWith($"Session {sid} ended"),
["calls"] = log,
};
}
var one = await OneSession();
var perCall = await PerCallSessions();
Console.WriteLine(JsonSerializer.Serialize(new Dictionary<string, object?> { ["language"] = "csharp", ["one_session"] = one, ["per_call"] = perCall, ["measured_at"] = DateTime.UtcNow.ToString("yyyy-MM-dd") },
new JsonSerializerOptions { WriteIndented = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
// No MCP SDK here: the official Go SDK needs Go 1.23+, so this speaks the protocol directly —
// JSON-RPC 2.0 over Streamable HTTP, with the standard library only.
// env: CDPFLEET_API_KEY, PROXY_URL
package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
const mcpURL = "https://cdpfleet.com/mcp"
const extract = "() => [...document.querySelectorAll('article.product_pod')].map((a) => ({ title: a.querySelector('h3 a').title, price: a.querySelector('.price_color').textContent }))"
var key = os.Getenv("CDPFLEET_API_KEY")
var proxy = os.Getenv("PROXY_URL")
var httpc = &http.Client{Timeout: 120 * time.Second}
type Call struct {
Tool string `json:"tool"`
Ms int64 `json:"ms"`
Error bool `json:"error"`
WithInitializeMs *int64 `json:"with_initialize_ms,omitempty"`
}
// One MCP session. A script authenticates with its API key as a bearer token.
type Conn struct {
session string
nextID int
}
// post sends one JSON-RPC message and returns the response for its id (the server answers
// with JSON or with a short event stream).
func (c *Conn) post(msg map[string]any) (map[string]any, error) {
body, _ := json.Marshal(msg)
req, _ := http.NewRequest("POST", mcpURL, bytes.NewReader(body))
req.Header.Set("authorization", "Bearer "+key)
req.Header.Set("content-type", "application/json")
req.Header.Set("accept", "application/json, text/event-stream")
req.Header.Set("mcp-protocol-version", "2025-06-18")
if c.session != "" {
req.Header.Set("mcp-session-id", c.session)
}
res, err := httpc.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if s := res.Header.Get("mcp-session-id"); s != "" {
c.session = s
}
if res.StatusCode == http.StatusAccepted { // a notification: nothing comes back
return nil, nil
}
if res.StatusCode != http.StatusOK {
b, _ := io.ReadAll(res.Body)
return nil, fmt.Errorf("HTTP %d: %s", res.StatusCode, b)
}
var payloads [][]byte
if strings.HasPrefix(res.Header.Get("content-type"), "text/event-stream") {
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 8<<20)
for sc.Scan() {
if line := sc.Text(); strings.HasPrefix(line, "data:") {
payloads = append(payloads, []byte(strings.TrimSpace(line[5:])))
}
}
} else {
b, _ := io.ReadAll(res.Body)
payloads = append(payloads, b)
}
for _, p := range payloads {
var m map[string]any
if json.Unmarshal(p, &m) == nil && m["id"] != nil && fmt.Sprint(m["id"]) == fmt.Sprint(msg["id"]) {
if e, ok := m["error"]; ok {
return nil, fmt.Errorf("JSON-RPC error: %v", e)
}
return m["result"].(map[string]any), nil
}
}
return nil, fmt.Errorf("no response for request %v", msg["id"])
}
func (c *Conn) request(method string, params map[string]any) (map[string]any, error) {
c.nextID++
return c.post(map[string]any{"jsonrpc": "2.0", "id": c.nextID, "method": method, "params": params})
}
func connect() (*Conn, error) {
c := &Conn{}
if _, err := c.request("initialize", map[string]any{
"protocolVersion": "2025-06-18", "capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "cdpfleet-case-79", "version": "1.0.0"},
}); err != nil {
return nil, err
}
_, err := c.post(map[string]any{"jsonrpc": "2.0", "method": "notifications/initialized"})
return c, err
}
// terminate ends the MCP session (DELETE), as a client does when it's done.
func (c *Conn) terminate() {
req, _ := http.NewRequest("DELETE", mcpURL, nil)
req.Header.Set("authorization", "Bearer "+key)
req.Header.Set("mcp-session-id", c.session)
if res, err := httpc.Do(req); err == nil {
res.Body.Close()
}
}
func (c *Conn) call(log *[]Call, name string, args map[string]any) (string, bool) {
t := time.Now()
r, err := c.request("tools/call", map[string]any{"name": name, "arguments": args})
if err != nil {
panic(err)
}
var parts []string
for _, item := range r["content"].([]any) {
if m := item.(map[string]any); m["type"] == "text" {
parts = append(parts, m["text"].(string))
}
}
isErr, _ := r["isError"].(bool)
*log = append(*log, Call{Tool: name, Ms: time.Since(t).Milliseconds(), Error: isErr})
return strings.Join(parts, "\n"), isErr
}
// A shared proxy drops the odd connection: one retry of a failed page load.
func (c *Conn) navigate(log *[]Call, url string) (string, bool) {
text, isErr := c.call(log, "navigate", map[string]any{"url": url})
if isErr {
return c.call(log, "navigate", map[string]any{"url": url})
}
return text, isErr
}
func firstLine(s string) string { return strings.SplitN(s, "\n", 2)[0] }
var launchedRe = regexp.MustCompile(`^Launched (\S+) (\S+) \((\w+), (\d+) thread`)
var sessionRe = regexp.MustCompile(`session ([0-9a-f-]{36})`)
// ---- 1. One MCP session for the whole job, as Claude or Cursor keep one.
func oneSession() map[string]any {
log := []Call{}
out := map[string]any{}
t := time.Now()
c, err := connect()
if err != nil {
panic(err)
}
log = append(log, Call{Tool: "initialize", Ms: time.Since(t).Milliseconds()})
t = time.Now()
list, err := c.request("tools/list", map[string]any{})
if err != nil {
panic(err)
}
log = append(log, Call{Tool: "tools/list", Ms: time.Since(t).Milliseconds()})
names := []string{}
for _, x := range list["tools"].([]any) {
names = append(names, x.(map[string]any)["name"].(string))
}
sort.Strings(names)
out["mcp_session"] = c.session != ""
out["tools"] = names
text, _ := c.call(&log, "launch_browser", map[string]any{"browser": "chromium", "headless": true, "proxy": proxy, "record": false})
if m := launchedRe.FindStringSubmatch(text); m != nil {
w, _ := strconv.Atoi(m[4])
out["browser"], out["version"], out["resource_class"], out["weight"] = m[1], m[2], m[3], w
}
sid := ""
if m := sessionRe.FindStringSubmatch(text); m != nil {
sid = m[1]
}
out["session_id"] = sid
nav, _ := c.navigate(&log, "https://books.toscrape.com/")
out["navigate"] = firstLine(nav)
snap, _ := c.call(&log, "snapshot", map[string]any{})
out["snapshot_chars"] = len([]rune(snap))
snapList, _ := c.call(&log, "snapshot", map[string]any{"selector": "ol.row"})
out["snapshot_list_chars"] = len([]rune(snapList))
html, _ := c.call(&log, "evaluate", map[string]any{"expression": "document.documentElement.outerHTML.length"})
out["html_chars"], _ = strconv.Atoi(strings.TrimSpace(html))
booksText, _ := c.call(&log, "evaluate", map[string]any{"expression": extract})
var books []map[string]string
json.Unmarshal([]byte(booksText), &books)
out["books"] = len(books)
out["first_book"] = books[0]
closed, _ := c.call(&log, "close_browser", map[string]any{})
out["closed"] = strings.HasPrefix(closed, "Session "+sid+" ended")
after, afterErr := c.call(&log, "navigate", map[string]any{"url": "https://books.toscrape.com/"})
out["after_close"] = map[string]any{"error": afterErr, "text": firstLine(after)}
acc, _ := c.call(&log, "account", map[string]any{})
var account map[string]any
json.Unmarshal([]byte(acc), &account)
out["live_sessions_after"] = account["live_sessions"]
c.terminate()
out["calls"] = log
return out
}
// ---- 2. A new MCP session for every tool call, as ChatGPT does.
func perCallSessions() map[string]any {
log := []Call{}
sessions := []string{}
once := func(name string, args map[string]any) string {
t := time.Now()
c, err := connect()
if err != nil {
panic(err)
}
var text string
if name == "navigate" {
text, _ = c.navigate(&log, args["url"].(string))
} else {
text, _ = c.call(&log, name, args)
}
sessions = append(sessions, c.session) // no DELETE: the client just goes away
ms := time.Since(t).Milliseconds()
log[len(log)-1].WithInitializeMs = &ms
return text
}
launched := once("launch_browser", map[string]any{"browser": "chromium", "headless": true, "proxy": proxy, "record": false})
sid := ""
if m := sessionRe.FindStringSubmatch(launched); m != nil {
sid = m[1]
}
nav := once("navigate", map[string]any{"url": "https://books.toscrape.com/catalogue/page-2.html"})
where := once("evaluate", map[string]any{"expression": `({ url: location.href, books: document.querySelectorAll("article.product_pod").length })`})
closed := once("close_browser", map[string]any{})
var sees map[string]any
json.Unmarshal([]byte(where), &sees)
distinct := map[string]bool{}
for _, s := range sessions {
distinct[s] = true
}
return map[string]any{
"mcp_sessions": len(sessions),
"distinct_mcp_sessions": len(distinct),
"browser_session_id": sid,
"navigate": firstLine(nav),
"later_session_sees": sees,
"closed_same_browser": strings.HasPrefix(closed, "Session "+sid+" ended"),
"calls": log,
}
}
func main() {
one := oneSession()
perCall := perCallSessions()
b, _ := json.MarshalIndent(map[string]any{"language": "go", "one_session": one, "per_call": perCall,
"measured_at": time.Now().UTC().Format("2006-01-02")}, "", " ")
fmt.Println(string(b))
}
What we got
| MCP session | Call | Time | Result | Measured |
|---|---|---|---|---|
| one for the whole run | initialize | 261 ms | session id issued | 2026-10-31 |
| one for the whole run | tools/list | 46 ms | 13 tools: account, click, close_browser, evaluate, launch_browser, list_browsers, live_view, navigate, press, screenshot, snapshot, type, wait_for | 2026-10-31 |
| one for the whole run | launch_browser | 584 ms | chromium 148.0.7778.96, linux_headless, 1 thread | 2026-10-31 |
| one for the whole run | navigate | 3,317 ms | 200 https://books.toscrape.com/ | 2026-10-31 |
| one for the whole run | snapshot | 190 ms | 16,662 characters (whole page) | 2026-10-31 |
| one for the whole run | snapshot | 121 ms | 9,955 characters (the product list) | 2026-10-31 |
| one for the whole run | evaluate | 77 ms | HTML: 50,989 characters | 2026-10-31 |
| one for the whole run | evaluate | 65 ms | 20 books, first: A Light in the Attic (£51.77) | 2026-10-31 |
| one for the whole run | close_browser | 29 ms | session ended | 2026-10-31 |
| one for the whole run | navigate | 25 ms | error: No browser is open. Call launch_browser first. | 2026-10-31 |
| one for the whole run | account | 107 ms | 0 live sessions | 2026-10-31 |
| new for each call (1 of 4, 4 distinct) | launch_browser | 486 ms (541 ms with initialize) | browser session 87f73103… | 2026-10-31 |
| new for each call (2 of 4, 4 distinct) | navigate | 4,413 ms (4,480 ms with initialize) | 200 https://books.toscrape.com/catalogue/page-2.html | 2026-10-31 |
| new for each call (3 of 4, 4 distinct) | evaluate | 36 ms (137 ms with initialize) | 20 books at /catalogue/page-2.html | 2026-10-31 |
| new for each call (4 of 4, 4 distinct) | close_browser | 31 ms (91 ms with initialize) | the same browser session ended | 2026-10-31 |
From the Node.js run on 2026-10-31. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.
Raw output (Node.js)
{
"language": "node",
"one_session": {
"mcp_session": true,
"tools": [
"account",
"click",
"close_browser",
"evaluate",
"launch_browser",
"list_browsers",
"live_view",
"navigate",
"press",
"screenshot",
"snapshot",
"type",
"wait_for"
],
"browser": "chromium",
"version": "148.0.7778.96",
"resource_class": "linux_headless",
"weight": 1,
"session_id": "5b23c145-66cd-4d42-9435-171dd9af475d",
"navigate": "200 https://books.toscrape.com/",
"snapshot_chars": 16662,
"snapshot_list_chars": 9955,
"html_chars": 50989,
"books": 20,
"first_book": {
"title": "A Light in the Attic",
"price": "£51.77"
},
"closed": true,
"after_close": {
"error": true,
"text": "No browser is open. Call launch_browser first."
},
"live_sessions_after": 0,
"calls": [
{
"tool": "initialize",
"ms": 261,
"error": false
},
{
"tool": "tools/list",
"ms": 46,
"error": false
},
{
"tool": "launch_browser",
"ms": 584,
"error": false
},
{
"tool": "navigate",
"ms": 3317,
"error": false
},
{
"tool": "snapshot",
"ms": 190,
"error": false
},
{
"tool": "snapshot",
"ms": 121,
"error": false
},
{
"tool": "evaluate",
"ms": 77,
"error": false
},
{
"tool": "evaluate",
"ms": 65,
"error": false
},
{
"tool": "close_browser",
"ms": 29,
"error": false
},
{
"tool": "navigate",
"ms": 25,
"error": true
},
{
"tool": "account",
"ms": 107,
"error": false
}
]
},
"per_call": {
"mcp_sessions": 4,
"distinct_mcp_sessions": 4,
"browser_session_id": "87f73103-8085-4ebe-956a-a86284f16b62",
"navigate": "200 https://books.toscrape.com/catalogue/page-2.html",
"later_session_sees": {
"url": "https://books.toscrape.com/catalogue/page-2.html",
"books": 20
},
"closed_same_browser": true,
"calls": [
{
"tool": "launch_browser",
"ms": 486,
"error": false,
"with_initialize_ms": 541
},
{
"tool": "navigate",
"ms": 4413,
"error": false,
"with_initialize_ms": 4480
},
{
"tool": "evaluate",
"ms": 36,
"error": false,
"with_initialize_ms": 137
},
{
"tool": "close_browser",
"ms": 31,
"error": false,
"with_initialize_ms": 91
}
]
},
"measured_at": "2026-10-31"
}Takeaways
- An MCP call costs tens of milliseconds; the page load is the slow part. On 2026-10-31, across five runs (one per language),
evaluateanswered in 61–95 ms,close_browserin 20–69 ms andtools/listin 21–102 ms.launch_browserreturned a running headless Chromium in 496–663 ms, whilenavigatetook 3.3–7.3 s — the page loading through the proxy, not the protocol. - The snapshot is a third of the HTML, the product list a fifth. The catalogue page's accessibility snapshot was 16,662 characters against 50,989 of HTML (33%); the snapshot of just its product list was 9,955 (20%), in every run. That is what the assistant reads to decide what to click (pages to text for LLMs).
- The browser belongs to the API key, not to the MCP session. With a new MCP session for every call, as ChatGPT makes them, the browser launched in the first session was still there in the next ones: the page opened in the second was the page the third read (20 books on page 2), and
close_browserin the fourth ended that same browser session — in all five runs. A new MCP session cost 41–160 ms on top of the call. - Closing really closes. After
close_browser, anavigatewas refused at once ("No browser is open. Call launch_browser first."), andaccountshowed 0 live sessions in every run. A browser left open ends by its idle timeout instead, still billed until then. - Four official SDKs and plain HTTP all worked the same. TypeScript, Python, Java and C# used their MCP SDKs; Go used about 260 lines of standard-library JSON-RPC (the whole program), since its official SDK needs Go 1.23. A script authenticates with
Authorization: Bearer <API key>; assistants use OAuth instead (MCP setup).