[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #79 · 2026-10-31 · Example · Easy · for [ai agents](https://cdpfleet.com/ai-agents), [llm data](https://cdpfleet.com/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.

[Chromium](https://cdpfleet.com/docs/browsers/chromium)

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

1.  Connect: initialize an MCP session with the API key as bearer token and list the tools.
2.  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`.
3.  Close the browser, try one more `navigate`, and read `account`; end the MCP session.
4.  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](https://github.com/cdpfleet/cdpfleet/tree/main/cases/79-mcp-in-action), 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:pass@proxy.example.com:8000`

 

```node
// npm install @modelcontextprotocol/sdk@1.32.2
// 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));
```

```python
# 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())
```

```java
// 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
  }
}
```

```csharp
// 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 }));
```

```go
// 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), `evaluate` answered in 61–95 ms, `close_browser` in 20–69 ms and `tools/list` in 21–102 ms. `launch_browser` returned a running headless Chromium in 496–663 ms, while `navigate` took 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](https://cdpfleet.com/docs/cases/page-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_browser` in 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`, a `navigate` was refused at once ("No browser is open. Call launch\_browser first."), and `account` showed 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](https://cdpfleet.com/docs/mcp/setup)).

---
Source: https://cdpfleet.com/docs/cases/mcp-in-action · Markdown: https://cdpfleet.com/docs/cases/mcp-in-action.md · All docs for AI tools: https://cdpfleet.com/llms.txt
