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/discoverfor identity,tools/listfor the tool catalog — with noinitializehandshake and no session. - Call a remote tool with
tools/calland flatten its content blocks into a plain string. - Bridge those remote tools into the
LanguageModelintent 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.
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.
- JavaScript
- TypeScript
// 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;
}
interface JsonRpcRequest { jsonrpc: '2.0'; id?: number; method: string; params?: any; }
interface Transport { send(request: JsonRpcRequest): Promise<any>; }
const PROTOCOL_VERSION = '2026-07-28';
const CLIENT_INFO = { name: 'browser-mcp-client', version: '0.1.0' };
const withMeta = (params: Record<string, unknown>) => ({
...params,
_meta: {
'io.modelcontextprotocol/protocolVersion': PROTOCOL_VERSION,
'io.modelcontextprotocol/clientInfo': CLIENT_INFO,
'io.modelcontextprotocol/clientCapabilities': {},
},
});
function makeHttpTransport({ url, token }: { url: string; token?: string }): Transport {
return {
async send(request) {
const p = request.params || {};
const name = p.name || p.uri;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'MCP-Protocol-Version': PROTOCOL_VERSION,
'Mcp-Method': request.method,
};
if (name) headers['Mcp-Name'] = String(name);
if (token) headers.Authorization = 'Bearer ' + token;
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(request) });
const text = await res.text();
if (!res.ok && res.status !== 202) throw new Error('HTTP ' + res.status + ': ' + text.slice(0, 200));
if (!text) return null;
const ct = res.headers.get('content-type') || '';
return ct.includes('text/event-stream') ? parseSse(text) : JSON.parse(text);
},
};
}
let id = 0;
const rpc = (t: Transport, method: string, params: Record<string, unknown>) =>
t.send({ jsonrpc: '2.0', id: ++id, method, params: withMeta(params) });
async function connectTransport(t: Transport) {
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 };
}
function parseSse(text: string) {
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;
}
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.
- JavaScript
- TypeScript
// 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');
}
async function callTool(t: Transport, name: string, args: Record<string, unknown>): Promise<string> {
const res = await rpc(t, 'tools/call', { name, arguments: args });
const content: any[] = (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.
- JavaScript
- TypeScript
// 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();
type Step = { toolName: string; args?: unknown; reply?: string };
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".' },
},
} as const;
// 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') throw new Error('unavailable');
const session = await LanguageModel.create({
...langOpts,
initialPrompts: [{ role: 'system', content: buildSystemPrompt(tools) }],
});
let next = question;
for (let i = 0; i < 8; i++) {
// responseConstraint rides on each prompt(), not create()
const step = extractJsonFromResponse(await session.prompt(next, { responseConstraint: INTENT_SCHEMA })) as Step | null;
if (!step || step.toolName === 'done') { render(step?.reply ?? 'Done.'); break; }
const result = await callTool(transport, step.toolName, coerceArgs(step.args));
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.
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
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.
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.
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.
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.
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_metablock and (over HTTP) anMCP-Protocol-Versionheader. Modern MCP (2026-07-28) dropped theinitializehandshake and the session entirely; legacy servers still want both. tools/callreturns content blocks; flatten them to a string before the model reads them.- The bridge to on-device Nano is the same intent loop: a
responseConstraintschema, 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
- WebMCP: the page as a tool surface — the inverse, where your page is the server other agents call.
- Structured output & tool calling — the intent loop this lesson points at remote tools.
- Observability & tracing — watch every tool call, its latency, and the session, with no backend.
- MCP Client API reference — the hosted walkthrough of the connect flow and the CORS rules.
Next: Observability & tracing