Skip to main content

An MCP client in the browser

So you taught Gemini Nano to call your own functions back in the tool-calling lesson, and turned your page into a tool surface in WebMCP. Now the browser becomes an MCP client — it dials a remote MCP server over Streamable HTTP, lists the tools that server exposes, and lets the on-device model drive them. Same protocol as WebMCP, opposite end of the wire: your tab does the calling now, with no backend of your own in the middle.

What you'll build

  • Run the modern MCP connect flow over Streamable HTTP — server/discover for identity, tools/list for the tool catalog — with no initialize handshake and no session.
  • Call a remote tool with tools/call and flatten its content blocks into a plain string.
  • Bridge those remote tools into the LanguageModel intent loop so on-device Nano drives them.
  • Get past the browser's CORS wall with a proxy, and send a bearer token without leaking it.
  • Ship a mock server so the whole loop runs offline when no remote server is around.
Prerequisites

Desktop Chrome with built-in AI. This lesson stands on two earlier ones: the intent loop from Structured output & tool calling and the protocol from WebMCP — read those first. If LanguageModel isn't there yet, start with Setup & the availability lifecycle; for which APIs are stable on which Chrome, see the compatibility matrix.

Speak the protocol over Streamable HTTP

Let's start on the wire. Modern MCP — revision 2026-07-28 — threw out the handshake. No initialize, no session to open, no notifications/initialized. Every request stands on its own: it carries its protocol version in a _meta field, and over HTTP that same version rides in an MCP-Protocol-Version header. The server reads each request cold. So the client is really three methods — server/discover to ask who's there and what it can do, tools/list to read the menu, tools/call to run one. The transport underneath is Streamable HTTP — a plain POST to a single endpoint, except the reply can come back as JSON or as a Server-Sent Events stream, so you accept both.

One detail the SDKs hide and you can't: every request body needs that _meta block — the protocol version, your clientInfo, and a clientCapabilities object — and over HTTP three mirror headers go with it: MCP-Protocol-Version (which must match the _meta version exactly, or it's a 400), Mcp-Method, and Mcp-Name on a tools/call. Get the version wrong and the server answers 400 with an UnsupportedProtocolVersionError that lists the versions it does speak — pick one and retry. There's no Mcp-Session-Id to echo anymore; that was the legacy era (2025-11-25 and earlier). Plenty of servers still live there, so a production client tries modern first and falls back to initialize — more on that below.

demo.js
// The browser is the MCP client. Modern MCP has no handshake and no session:
// every request names its protocol version in a `_meta` block and (over HTTP) in
// an `MCP-Protocol-Version` header. One primitive — send(request) — POSTs it.
const PROTOCOL_VERSION = '2026-07-28';
const CLIENT_INFO = { name: 'browser-mcp-client', version: '0.1.0' };

// The per-request metadata every modern request must carry.
const withMeta = (params) => ({
...params,
_meta: {
'io.modelcontextprotocol/protocolVersion': PROTOCOL_VERSION,
'io.modelcontextprotocol/clientInfo': CLIENT_INFO,
'io.modelcontextprotocol/clientCapabilities': {},
},
});

function makeHttpTransport({ url, token }) {
return {
async send(request) {
const p = request.params || {};
const name = p.name || p.uri; // params.name / params.uri drive the Mcp-Name mirror
const headers = {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'MCP-Protocol-Version': PROTOCOL_VERSION, // must match _meta; exact casing
'Mcp-Method': request.method, // mirror header routers can read
};
if (name) headers['Mcp-Name'] = String(name); // required for tools/call
if (token) headers.Authorization = 'Bearer ' + token; // optional bearer
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(request) });
const text = await res.text();
// A 400 carrying UnsupportedProtocolVersionError (-32022) lists the versions
// the server speaks; pick one and retry (the repo demo does this bump).
if (!res.ok && res.status !== 202) throw new Error('HTTP ' + res.status + ': ' + text.slice(0, 200));
if (!text) return null; // 202 Accepted: no body
const ct = res.headers.get('content-type') || '';
return ct.includes('text/event-stream') ? parseSse(text) : JSON.parse(text);
},
};
}

let id = 0;
const rpc = (t, method, params) => t.send({ jsonrpc: '2.0', id: ++id, method, params: withMeta(params) });

// server/discover for identity + capabilities, then tools/list. No handshake.
async function connectTransport(t) {
const disc = (await rpc(t, 'server/discover', {})).result || {};
const list = (await rpc(t, 'tools/list', {})).result || {};
const serverInfo = (disc._meta && disc._meta['io.modelcontextprotocol/serverInfo']) || {};
return { server: serverInfo, tools: list.tools }; // [{name, description, inputSchema}]
}

// Streamable HTTP may stream the reply as SSE; pull the data line and parse it.
function parseSse(text) {
const data = text.split(/\r?\n/).filter((l) => l.startsWith('data:'))
.map((l) => l.slice(5).trim()).join('\n');
return data ? JSON.parse(data) : null;
}
Modern and legacy servers

2026-07-28 is a redesign, so the world is split. Modern servers (this revision and later) read per-request _meta; legacy servers (2025-11-25 and earlier) still want the initialize handshake and a session. The spec's own answer is a dual-era client: try a modern request first, and if the reply is a 400 that isn't a recognized modern error, fall back to initialize. The demo here speaks modern only — its mock server does too — so the fallback stays a description, not code. Real servers are mid-migration; expect to meet both.

Call a remote tool

Let's call one. tools/call takes the tool name and an arguments object, and the server answers with content blocks — usually a text block, sometimes an image or an embedded resource. A model can't reason over a block array, so you flatten it: pull the text, stringify anything else, hand back one string. That string is what the model reads next.

demo.js
// One JSON-RPC request; flatten the content blocks to a string for the model.
async function callTool(t, name, args) {
const res = await rpc(t, 'tools/call', { name, arguments: args });
const content = (res.result && res.result.content) || [];
return content.map((b) => (b.type === 'text' ? b.text : JSON.stringify(b))).join('\n');
}

Hand the tools to Gemini Nano

Now the payoff. You already have the loop — you're just pointing it at tools you didn't write. Constrain Nano to the same { toolName, args, reply } schema, drop the server's tool list into the system prompt so the model knows the menu, then run the cycle: prompt, parse, tools/call, feed the result back. The on-device model decides which remote tool to hit and with what arguments; your JavaScript makes the network call.

Nano never touches the wire. It just points.

Why the loop and not a native tools array? Because there isn't one to reach for. Passing a tools array to LanguageModel.create() isn't a supported browser feature — the on-device model exposes no built-in tool-calling path, so the intent loop is the real pattern: constrain the model to name a tool, run it in JavaScript, feed the result back. It leans only on responseConstraint, which is shipped. Same trade you made in the tool-calling lesson.

One guard in the loop: even with the schema pinning args to an object, a small model will now and then hand it back as a JSON string, so coerceArgs re-parses that case into an object before the tools/call.

demo.js
// The intent schema and fence-stripping parse are the same as the tool lesson.
const INTENT_SCHEMA = {
type: 'object',
required: ['toolName'],
additionalProperties: false,
properties: {
toolName: { type: 'string', description: 'Tool to call next, or "done" to reply.' },
args: { type: 'object', description: 'Arguments for the tool.' },
reply: { type: 'string', description: 'Final answer — only when toolName is "done".' },
},
};

// Same language options to availability() and create() — availability() answers
// for the exact request you'll make.
const langOpts = {
expectedInputs: [{ type: 'text', languages: ['en'] }],
expectedOutputs: [{ type: 'text', languages: ['en'] }],
};
if ((await LanguageModel.availability(langOpts)) !== 'available') return;
const session = await LanguageModel.create({
...langOpts,
initialPrompts: [{ role: 'system', content: buildSystemPrompt(tools) }], // lists the server's tools
});

let next = question;
for (let i = 0; i < 8; i++) { // cap the turns (MAX_TOOL_CALLS)
// responseConstraint rides on each prompt(), not create()
const step = extractJsonFromResponse(await session.prompt(next, { responseConstraint: INTENT_SCHEMA })); // fence-stripping parse
if (!step || step.toolName === 'done') { render(step?.reply ?? 'Done.'); break; }
const result = await callTool(transport, step.toolName, coerceArgs(step.args)); // remote tools/call
next = `Tool "${step.toolName}" result: ${result}. Call the next tool, or emit {"toolName":"done","reply":"..."}.`;
}
session.destroy();

Get past CORS

Ok, the part the demo can't paper over. A desktop MCP client — Claude Desktop, an IDE, a CLI — talks to servers over stdio or from a trusted process, no browser in the way. Yours runs inside a tab, so every call is a cross-origin fetch, which means CORS. And here's the uncomfortable truth: most MCP servers in the wild assume a native client and send no CORS headers at all, so they fail at the very first request with a bare "Failed to fetch" and no detail, because the browser hides cross-origin failures on purpose.

For a real connection the server has to allow your origin — and the one that bites: the request headers you send (MCP-Protocol-Version, Mcp-Method, Mcp-Name, and Authorization for a token) aren't CORS-simple, so the browser fires a preflight OPTIONS first and the server has to name them in Access-Control-Allow-Headers. Miss that and the real POST never leaves. (A legacy, handshake-era server adds one more: it hands back an Mcp-Session-Id response header the browser only surfaces if it's in Access-Control-Expose-Headers.) If you don't control the server, you run a CORS proxy: a dependency-free relay that sits between the tab and the target, server-to-server where CORS doesn't apply, and paints the headers back on the way out.

# A tiny relay: browser -> localhost -> target server (no CORS on that last hop).
TARGET='https://example.com/mcp' node ./mcp-cors-proxy.mjs
# then point the demo's Server URL at the proxy's localhost address.

The bearer token, if the server wants one, rides as Authorization: Bearer <token> on every request — you saw it in the transport above. Keep it in memory, never in localStorage, and scope it down. One honest gap to know: that one token rides every request the same, so any tool you enable runs with the same credential — MCP has no per-tools/call scope. A demo token in a browser tab is a demo token. Not your production admin key.

Try it

Run it locally: open 14-mcp-client/index.html from the chrome-ai-course repo in desktop Chrome. It ships an in-page mock MCP server, so it runs offline: Connect lists add, multiply, and echo; then asking "What is 21 plus 21, then multiply the result by 2?" logs a tools/call to add, then one to multiply, and replies 84. Switch to "Remote MCP server (URL)" to speak to a real one.

The hosted MCP client demo is the remote-only cut — no in-page mock, just a Server URL and an optional Bearer token to point at a live MCP endpoint (its API walkthrough has the full protocol).

Requires: desktop Chrome with Gemini Nano available for the agent step — connecting and listing tools works without it. See Setup & the availability lifecycle.

Gotchas & troubleshooting

connect() throws "Failed to fetch" or a TypeError

Symptom: the first request (server/discover) rejects immediately with a network error and no detail. Cause: the remote server sent no Access-Control-Allow-Origin for your origin, so the browser blocked the response before your code ever saw it. Fix: connect to a server you control, or route through a CORS proxy that adds the header. The browser enforces this — there's no client-side switch to turn it off.

The POST never leaves the browser (preflight fails)

Symptom: a remote connect fails and the network panel shows an OPTIONS with no matching POST behind it. Cause: the modern request headers (MCP-Protocol-Version, Mcp-Method, Mcp-Name) aren't CORS-simple, so the browser preflights — and the server didn't list them in Access-Control-Allow-Headers. Fix: allow those headers (a proxy can). A legacy, handshake-era server has the mirror-image problem: it hands back an Mcp-Session-Id you can't read unless it's in Access-Control-Expose-Headers.

The token works in curl but not in the tab

Symptom: the bearer token authorizes fine from the terminal, the browser rejects the request. Cause: the server doesn't allow Authorization in Access-Control-Allow-Headers, so the OPTIONS preflight fails before the real request goes out. Fix: the server has to allow the header (a proxy can reflect it). And mind the gap — one token rides every request, so every enabled tool runs with that one credential.

The loop stalls partway through a request

Symptom: the agent hangs in the middle of a multi-tool answer. Cause: tools/call is one request and one response with no streaming, so a slow remote tool blocks the whole loop until it resolves — and you've capped the loop at eight turns. Fix: keep the cap, add a per-call timeout, and don't wire a known-slow tool into an interactive chat.

It connected last month, now the server rejects your version

Symptom: a connection that used to work starts failing at the first request. Cause: MCP is a moving target, and 2026-07-28 split the world into eras — a server that upgraded may no longer speak the version you send. A modern server says so out loud: 400 with an UnsupportedProtocolVersionError whose data.supported lists what it does speak. Fix: pick a version from that list and retry; if the reply is a bare 400 with no modern error body, you're talking to a legacy server — fall back to initialize and speak the older revision.

Recap

  • An MCP client is three JSON-RPC calls over Streamable HTTP — server/discover, tools/list, tools/call — each carrying its protocol version in a _meta block and (over HTTP) an MCP-Protocol-Version header. Modern MCP (2026-07-28) dropped the initialize handshake and the session entirely; legacy servers still want both.
  • tools/call returns content blocks; flatten them to a string before the model reads them.
  • The bridge to on-device Nano is the same intent loop: a responseConstraint schema, the tools in the system prompt, and a capped prompt → parse → call → feed-back cycle.
  • CORS is the wall: the server must allow your origin and the request headers you send (MCP-Protocol-Version, Mcp-Method, Mcp-Name) in its preflight, or you run a proxy. The bearer token stays in memory and rides every request the same — one credential for every tool, not one per call.

That's the whole stack — a model that never leaves the device, tools on a server you didn't build, a loop you can read in one screen. No SDK. No backend. Just a fetch and a schema.

You needed a fetch, not a platform.

Next steps


Next: Observability & tracing