# Smuggling WebMCP into ChatGPT

[WebMCP](https://developer.chrome.com/docs/ai/webmcp) is a pretty great idea: a web page offers a few tools ("add to cart", "search flights", "check out"), an AI agent in your browser calls them, and instead of the agent guessing at buttons, the page says what it can do, and the agent does it.

Almost nothing can use it yet, though, because it's behind a flag in Chrome, and the agents most people actually talk to have no idea it exists.

I wanted to know whether I could get ChatGPT to use a web page's tools today, without waiting for anyone.

As it turns out, yes, more or less, and it was an interesting hack. It took an iframe, a fake browser, and bending chat messages into something they probably weren't meant for.

## Wait, what's WebMCP?

Most agents use websites the hard way today: they take screenshots and click on things, or they read the page's HTML and guess which button does what, which is slow and can break whenever the page changes.

The other option is an [MCP](https://modelcontextprotocol.io/docs/learn/server-concepts) server, the standard way apps like ChatGPT call outside tools. The site builds a seperate server on the internet, and ChatGPT connects to it, logs in as you, and calls its tools over HTTP.

WebMCP puts the tools in the page instead. The page calls `registerTool()` with a name, a description, an input schema and a function, and an agent working in your browser can call it. That has some nice properties:

- **You're already logged in.** The tool runs in your tab, with your session, so there's no OAuth and no API keys.
- **The page knows things the server doesn't,** like what's on screen, the half-filled form and the filters you just picked.
- **You can see what's going on,** because the agent works in the page you're looking at, and the page can ask you before doing anything risky.
- **There's no new backend.** For a lot of sites the front end already is the product, so it's a few functions instead of a new server to build and secure.

Chrome has an early version behind a flag.

## What's stopping us?

WebMCP lives in your browser tab, and ChatGPT lives on OpenAI's servers, with nothing connecting the two.

ChatGPT only talks to servers. In [developer mode](https://developers.openai.com/apps-sdk) you can add your own MCP server to ChatGPT as a plugin, and ChatGPT calls it over HTTPS from OpenAI's side. A server on the internet can't see your browser, so it can't see a page's tools either. WebMCP, meanwhile, only exists in the browser: only code running in the same browser as the page can find and call its tools, and even that is behind a flag.

So yeah, on one side there's a model that can only call servers, and on the other, tools that only exist inside a tab. To connect them, I need something that runs in my browser, inside ChatGPT, and that the model can talk to. While ChatGPT doesn't offer that as one feature, it has a few pieces that add up to it.

## The players

There are four of them, two belonging to ChatGPT and two making up the plugin I built.

The first is **ChatGPT** itself: the chat page, running in my browser, and the model, running on OpenAI's servers. The second is the **page**, meaning whatever site I want ChatGPT to use, which here is a demo shop that offers a few tools.

Then there's the plugin's server, a small **MCP** server on the internet that gives ChatGPT two tools (which I'll get to), and which, like any server, can't see my browser.

And then there's the **panel**, the other half of the plugin. [ChatGPT apps](https://developers.openai.com/apps-sdk) can show a bit of UI in the chat, as an iframe running in my browser. Mine is a little web browser with an address bar and an iframe of its own, which is where the page loads. The panel can also post messages into the chat, as if I'd typed them, and that turns out to be the important bit.

The model can't reach the page, and the page can't reach the model, so the panel carries everything between them, mostly as chat messages, and that's the smuggling route.

## What it looks like

I mention the plugin and ask it to open the browser, and ChatGPT shows a small browser in the chat, with a button for a demo shop:

![The plugin's panel in a ChatGPT chat: a small browser with a Try the demo shop button](https://cdn.hashnode.com/uploads/covers/6a48a981c405023e280c9c99/997b89e9-9e6a-4261-9d19-99d29c2d0211.png align="center")

I click it, and the shop loads inside the panel and, before anything else happens, asks whether ChatGPT may use my cart. That question comes from the shop itself, so only my click can answer it:

![The demo shop in the panel, asking Let ChatGPT use your cart? with Allow and Don't allow buttons](https://cdn.hashnode.com/uploads/covers/6a48a981c405023e280c9c99/f490d8e3-6486-4561-a59d-0b769b6c9379.png align="center")

I click Allow, the panel's status bar lists the four tools the shop offers, and ChatGPT tells me what it can do:

![The demo shop open in the panel, its four tools in the status bar, and ChatGPT listing what it can do](https://cdn.hashnode.com/uploads/covers/6a48a981c405023e280c9c99/8f3247ce-7d54-48ee-93cf-6f856e200ae6.png align="center")

From there I just ask for things:

![After asking to add the blue mug, the shop's cart shows it and ChatGPT confirms](https://cdn.hashnode.com/uploads/covers/6a48a981c405023e280c9c99/767d0909-e3db-441e-9dc2-02404dc205e6.png align="center")

The mug really is in the cart, because the shop's own `add_to_cart` function ran in my browser, and ChatGPT answered from its actual result.

## The hack, step by step

There are four problems to solve: show a page, tell the model about its tools, let the model call one, and get the result back.

### Step 1: put a browser in the chat

![Step 1: I ask for the browser, the model calls open_browser on the plugin's server, the server says opened, ChatGPT shows the panel with the shop inside it, and the model confirms in the chat](https://cdn.hashnode.com/uploads/covers/6a48a981c405023e280c9c99/5ca58472-0379-44c2-90b9-b18fa64ceeac.png align="center")

The plugin's server offers ChatGPT a tool called `open_browser`. I ask for the browser, the model calls the tool, the server says "opened", and ChatGPT shows the panel in the chat: an address bar, back and forward buttons, a status bar, and an iframe for the page. I pick a page (here, the demo shop), it loads in my browser like any page I'd visit, the model confirms, and that turn is done.

So far so good, but the model still has no idea what the page can do.

### Step 2: tell the model what the page can do

![Step 2: I click Try the demo shop and Allow, the shop's tools go from the panel into a chat message, the model reads them and replies in the chat with what it can do](https://cdn.hashnode.com/uploads/covers/6a48a981c405023e280c9c99/71224f50-f39f-4a5b-a693-a2e4a6c0e73f.png align="center")

The panel connects to the page and gets its list of tools (more on how in a minute), and now they need to reach the model. Normally a plugin's tools come from its server, but the server never sees the page, so that's out, and the only route left is a chat message.

So the panel posts one. It's a single line for me `✓ Opened Kiln & Cotton: 4 tools`, with the tools attached as JSON for the model, and ChatGPT shows it for a moment, tucks it away, then gives the model a new turn. With the tools in front of it, the model says in plain english what it can do on this page. I never asked the model to list anything, the instructions attached to the panel's message did that.

### Step 3: call a tool the server has never heard of

![Step 3: I ask for the blue mug, the model calls call_site_tool, the server hands back ticket 3f2a, ChatGPT opens a new panel with the arguments and the ticket, which runs add_to_cart in the shop, and the model waits for the result](https://cdn.hashnode.com/uploads/covers/6a48a981c405023e280c9c99/f7e5148c-1f13-4856-918e-4929512c1ebd.png align="center")

The plugin's second tool is `call_site_tool`, and it's completely generic: a page, a tool name and some arguments. When I say "add the blue mug to my cart", the model calls it with `add_to_cart` and the blue mug. But that call goes to the plugin's server, and the server can't reach my browser, so all it can do is hand back a ticket number and tell the model to wait for the result.

So how does the call get to my browser? This is the part I like most: ChatGPT opens a fresh copy of the panel for every tool call, right there in the chat, and hands it the call's arguments and the ticket number, so ChatGPT is doing the delivery for me. The new panel loads the same shop (the cart lives in the URL, so it finds the same cart), connects, and runs `add_to_cart`. The blue mug lands in the cart while the older panel folds itself away, so it still looks like one browser.

Yes, this means the server is basically a receptionist that takes a message and points at the door. But it works, and it also means the server never sees the page, the tools or the results.

### Step 4: get the result back

![Step 4: the result goes from the shop into a chat message carrying ticket 3f2a, the model matches it to its call, and answers in the chat](https://cdn.hashnode.com/uploads/covers/6a48a981c405023e280c9c99/cf962c7d-e295-4ae6-be3d-2d849ce3342d.png align="center")

The panel posts the result as another chat message, `✓ add_to_cart ran on Kiln & Cotton. Result attached` carrying the same ticket number so the model knows which call it answers. It's the same trick as step 2, except this time the model is usually still busy with its reply, and this is where it got kinda finicky.

A message from the panel can either start a new turn, or be slipped into a reply that's still being written, and which one works depends on whether the model is busy at that moment. None of it is documented, and ChatGPT shows a red "Steering requires an active compatible turn" when you guess wrong, so the panel tries one, then the other, and waits a couple of seconds if both say no. Then the model reads the result and answers from what actually happened on the page.

One last bit of polish: at first the model narrated the wait, with "I've sent the request to add 1 blue mug ($12) to your cart. Awaiting confirmation from the shop." Thats accurate, but it gets old fast, so now it's told to say nothing until the result arrives, and the whole reply is the one line you see in the screenshot above.

## The whole picture

This is everything at once, what runs where and what flows between the pieces:

![The whole picture: inside your browser, ChatGPT's page holds the panel, which holds the shop. On the internet are the model and the plugin's server. Numbered arrows show the shop's tools going to the model as a chat message, the model calling call_site_tool, the server returning only a ticket number, ChatGPT opening a new panel with the arguments, that panel running add_to_cart in the shop, and the result going back to the model as another chat message.](https://cdn.hashnode.com/uploads/covers/6a48a981c405023e280c9c99/b15df6cb-7ea6-4f2c-b113-b345a3475181.png align="center")

The main thing to notice is that everything real happens on the left, in your browser, and the only thing on the right that knows about the shop is the model, which only knows what the page chose to tell it.

## Why hacks like this work

When a normal program calls a function, a few things are always true: the function was declared up front, the answer comes straight back from the call, and a message from the user really came from the user. Break any of those and a normal program falls over; and this plugin breaks all three:

- The shop's tools are never declared to ChatGPT, they just turn up in a chat message.
- When the model calls one, it gets a ticket number back instead of an answer.
- The real answer arrives later, as a chat message that looks like it came from me.

And the model doesn't mind, because a model doesn't see calls, return values or senders the way a program does. It sees one long stretch of text, so a message that says `add_to_cart ran, here's the result` reads to the model just like a proper tool result would. As long as the information lands in the conversation and it's clear what it is, the model picks it up.

I think that's worth remembering when you build for LLMs. We're used to strict systems, where every message has to arrive in the right shape, through the right pipe, but models are a lot more forgiving, and that leaves plenty of room for hacks like this one.

## Talking to the page: glottis

I skipped one thing in step 2, which is how the panel gets the page's tools in the first place.

The panel and the page are on different origins, so they can't touch each other's code, and all they can do is send each other messages with `postMessage`. As I've [said before](https://blog.danielbrain.ai/webmcp-skipped-a-step), messaging is a pain: you need a bunch of stuff on top, like requests and responses, errors, timeouts, and some way to know the other side is even there.

Native WebMCP would solve this, but it isn't on by default anywhere yet, so for now the page serves its tools with [glottis](https://github.com/bluepnume/glottis), a small library that makes a page's functions callable from another window. Here's a page offering a tool:

```js
import { z } from 'zod';
import { serve, tool } from 'glottis';

serve({
  clients: {
    remoteWindow: { allowed: true, domain: '*' }
  },
  exports: {
    say_hello: tool({
      description: 'Writes a greeting on the page for the given name.',
      input: z.object({
        name: z.string()
      }),
      handler: ({ name }) => {
        document.body.append(`Hello, ${ name }!`);
        return { greeted: name };
      }
    })
  }
});
```

The panel connects, gets `say_hello` back as an ordinary async function, and calls it. glottis also turns the zod schema into the JSON Schema the model sees and checks every call against it before the handler runs, and whatever the handler returns goes back to ChatGPT, while whatever it throws reaches ChatGPT as the error.

There's a lot more to glottis than that, and it has its own post:

[![WebMCP skipped a step, the companion post about glottis, on blog.danielbrain.ai](https://cdn.hashnode.com/uploads/covers/6a48a981c405023e280c9c99/c846122e-c075-48b8-b2e3-c428c5825117.png align="center")](https://blog.danielbrain.ai/webmcp-skipped-a-step)

## The shop asks first

Letting a chatbot drive your cart should probably involve asking you first, and here it's the page that asks, not ChatGPT and not the plugin. glottis lets `exports` be an async function, and holds the connection open until it returns:

```js
serve({
  clients: {
    remoteWindow: { allowed: true, domain: '*' }
  },
  exports: async ({ client }) => {
    if (!await askTheUser(`Let ${ client.origin } use your cart?`)) {
      throw new Error('The user declined.');
    }
    return tools;
  }
});
```

The prompt is drawn inside the the shop's own frame, so neither ChatGPT nor the plugin can click it, only me, and the demo shop remembers the answer per cart, so the panels that come later (one per call, remember) don't ask again.

A quick note on security, since this is the part people ask about: the plugin's server never sees a page, a tool or a result; the page decides who gets in and what they can do once they're in; and the model can only call the tools the page offers, so there's no "click anything anywhere" mode.

## The real thing, behind a flag

So that's glottis, but what about actual WebMCP? The plugin does that too, and the page barely has to change, because glottis can register the same tools with the browser's own WebMCP when it has it. It's one more line in `serve()`:

```js
serve({
  clients: {
    remoteWindow: { allowed: true, domain: '*' },

    // Also register the tools with native WebMCP, for the page that
    // frames this one (the plugin's panel)
    webMcp: { allowed: true, domain: location.ancestorOrigins[0] }
  },
  exports: { say_hello }
});
```

Under the hood that's WebMCP's `registerTool()`, with the tools exposed only to the panel's origin (and you can call that yourself, if you'd rather not use glottis). The panel then asks the browser for the shop's tools with `getTools()` and runs them with `executeTool()`, with no connection needed. If both routes are available the panel uses glottis, with the status bar saying which one it's on.

Except that WebMCP is guarded by a browser [permission](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Permissions_Policy) called `tools`, which every frame between ChatGPT's page and the shop needs, and ChatGPT's frames don't have it. So, just to see what would happen, I added it myself by injecting `allow="tools *"` into ChatGPT's iframes before they loaded, and it turns out that worked: ChatGPT's panel could see the shop's native tools and run them.

That experiment is now a launcher in [the repo](https://github.com/bluepnume/chatgpt-webmcp-bridge):

```sh
npx chatgpt-webmcp-bridge chrome
```

It opens your own Chrome in a profile of its own, with the flag on and every iframe given the permission.

## Where the seams show

This works, and I use it, but it's a hack, and there are rough edges:

- **Results are chat messages, not tool results.** The model has to wait for a message it was told is coming, and it nearly always does, but occasionally you'll see that red "Steering requires an active compatible turn" line while the panel works out which way in is open.
- **A panel per call.** Each call reloads the page in a fresh panel, which is fine for a shop whose state lives in the URL and in storage, and less fine for a page that keeps everything in memory.
- **Only pages you've listed.** ChatGPT only lets the panel show pages from origins the plugin's server lists up front (they become the panel's [`frame-src`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-src)), as exact origins with no wildcards, so "any page" really means any page whose origin you've listed on a server you run.
- **The page has to allow framing,** with no `X-Frame-Options` header that blocks it and a [`frame-ancestors`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-ancestors) that allows ChatGPT's sandbox.
- **Developer mode only.** OpenAI's app directory isn't going to approve an app that frames arbitrary pages, so for now this is a tool for people who build things.
- **Native WebMCP needs a flag and a launcher,** until browsers ship WebMCP and ChatGPT grants the permission.

I think most of these go away the day ChatGPT can talk to a page's WebMCP tools directly, and my bet is that it will, eventually. This is just a version you can use *today*, while the real thing gets built.

## Try it

You need ChatGPT with developer mode on, and then it's three steps:

1. In ChatGPT, open **Plugins › Add › Add custom MCP server**, choose no authentication, and use this server URL:

   ```plaintext
   https://webmcp.danielbrain.ai/chatgpt-webmcp-bridge/mcp
   ```
2. Start a chat, mention the plugin with `@`, and ask it to open the browser.
3. Click **Try the demo shop**, click **Allow**, and ask it to add something to your cart.

To point it at your own pages, run your own server (one command on Cloudflare Workers, or the package's `serve` command behind a tunnel) and list your page's origin. Everything is [on GitHub](https://github.com/bluepnume/chatgpt-webmcp-bridge), and it's [on npm](https://www.npmjs.com/package/chatgpt-webmcp-bridge) as `chatgpt-webmcp-bridge`, MIT licensed and very much 0.1.

Give it a try, and please steal any of these ideas. It's very much an experiment, so if you're building pages with tools, or agents that use them, I'd love to hear what you'd do with it, and what I got wrong.
