OpenAI's Site tools documentation makes the first step in adding WebMCP support for
ChatGPT look straightforward: check document.modelContext, register a JavaScript tool, and
call logic the page already uses. That part is straightforward.
ChatGPT's implementation is narrower than the full proposed WebMCP surface. The Site tools implementation currently discovers imperative JavaScript tools registered in the top-level page. It does not discover declarative tools defined through HTML form attributes or tools registered inside iframes. For PromptFax, that meant the tools had to live in the main page module rather than its existing MCP widget iframe.
The implementation question is what happens after ChatGPT calls the tool. If the web app uploads files,
persists workflow state, opens checkout, or hands work to backend providers, a correctly registered tool
can still fail well after its execute function starts.
I used PromptFax to work through those problems because it already had a complete human workflow: attach and edit a PDF, persist a destination, calculate a quote, open a human-controlled Stripe Checkout, wait for webhook-confirmed authorization, submit the document to a fax provider, poll delivery, and clean up temporary data. The WebMCP implementation wraps that existing workflow rather than creating a second agent-only version.
I added eight WebMCP tools that call the same JavaScript handlers used by the page. ChatGPT discovered all eight, then successfully read status, attached a document, checked the quote, and set the destination.
Then I asked the agent to open checkout.
The tool said Stripe was open. The page said Failed to fetch. A Google Cloud Storage CORS
preflight had returned HTTP 200, but the actual signed PUT failed inside the OpenAI browser
with net::ERR_FAILED and no HTTP response.
That one failure turned the implementation into a much more useful guide. Tool registration was not the hard part. The hard parts were moving a rebuilt PDF across a browser security boundary, knowing when an asynchronous action had really completed, keeping browser and server state aligned, and finishing cleanup after a real provider transaction.
This post uses that production test to walk through the implementation choices and the changes I made afterward. One caveat up front: the original test sent a real synthetic fax through production. The fixes described below passed local unit, browser, MCP, and 35-page stress tests, but I have not yet repeated the full live OpenAI-to-fax run with the fixed code.
Short answer: To support ChatGPT WebMCP today, register imperative JavaScript tools in the top-level page, wrap the handlers your interface already uses, persist every state those tools can change, wait for user-visible completion, and test the complete workflow inside ChatGPT's browser. Declarative form tools and tools registered inside iframes are part of the broader WebMCP proposal, but ChatGPT does not currently discover them.
The implementation path is:
- Register imperative tools in the top-level page that ChatGPT can discover.
- Wrap the page's existing handlers instead of creating a parallel agent workflow.
- Keep payment and other consequential actions in the visible human interface.
- Persist any workflow state a tool is allowed to change.
- Wait for asynchronous work to reach a user-visible result before returning success.
- Provide a same-origin fallback when the browser must transfer files across origins.
- Make post-action cleanup idempotent and retryable.
- Test both the supported WebMCP surface and the complete provider-backed workflow.
Start with the workflow your web app already has
OpenAI calls its WebMCP implementation Site tools. In the ChatGPT desktop app's built-in browser, ChatGPT Work and Codex can discover tools registered by the current page. The page and the agent share the same live browser state and signed-in session.
That is different from PromptFax's remote MCP server. Remote MCP tools operate persisted server-side sessions and do not need the website to be open. WebMCP tools exist inside the current page. They can use its editable document assembly, visible checkout, and existing event handlers directly.
PromptFax supports both because they solve different problems.
For ChatGPT, the practical constraints are simple:
- Register tools imperatively from JavaScript in the top-level page.
- Keep the normal page usable when Site tools are unavailable or the agent cannot complete a task.
- Use remote MCP separately when the workflow must continue without the page being open.
| Surface | Where the tool runs | State it naturally sees | Best fit |
|---|---|---|---|
| WebMCP | The live top-level page | Current document edits, destination field, visible checkout, current UI | A person and agent working in the same tab |
| Remote MCP | PromptFax's server | Persisted workflow session, payment state, provider state | Host-independent automation and reconnectable workflows |
The WebMCP registration itself is small. PromptFax checks the current API location and registers tools that delegate to existing page logic:
const modelContext = document.modelContext ?? navigator.modelContext;
await modelContext.registerTool({
name: "set_destination_number",
description: "Set the destination on the live PromptFax page.",
inputSchema: {
type: "object",
properties: {
faxNumber: { type: "string" }
},
required: ["faxNumber"],
additionalProperties: false
},
execute: async ({ faxNumber }) => {
await commitDestination(faxNumber);
return getCurrentWorkflowStatus();
}
});
The browser invokes execute directly. PromptFax does not listen for a DOM event and the call
does not pass through the remote /mcp endpoint.
I intentionally did not expose a WebMCP send_fax tool. The agent can prepare the workflow and
open Stripe, but a person completes payment authorization in the page. PromptFax sends only after Stripe
confirms authorization by webhook. That keeps the consequential step in the existing human-visible flow.
Test the complete workflow, not just tool discovery
I wanted to test more than tool discovery. The flow used:
- PromptFax production at
promptfax.app - OpenAI's built-in browser
- A public synthetic three-page PDF
- A configured synthetic fax receiver
- A one-use review grant
- A real Stripe Checkout discounted to $0
- Real Stripe webhook handling and fax-provider submission
- Verification markers that separated the run from customer traffic
The review grant mattered. It exercised the normal Checkout, webhook, fax, and cleanup paths without charging a customer. It did not bypass payment state or turn the fax into a mock.
Here is what happened:
| Step | Result | What I observed |
|---|---|---|
| Discover page tools | Pass | OpenAI found all 8 PromptFax tools |
| Attach the synthetic PDF by URL | Pass | 3 pages appeared in the live page |
| Read the quote | Pass | Tool and UI both showed $2.00 |
| Set the destination | Pass | Workflow advanced to ready for checkout |
| Open checkout | Fail | Tool claimed success; signed GCS PUT failed with net::ERR_FAILED |
| Associate the PDF through a controlled server workaround | Pass | The same workflow continued after reload |
| Complete Stripe Checkout | Pass | Quote remained $2.00; grant-adjusted Checkout total was $0 |
| Send and receive the fax | Pass | Telnyx reported delivery and SignalWire received 3 pages |
| Render the local delivery report | Pass | The browser showed 3 delivered pages and $0 charged |
| Clear remote document and preview data | Partial | Document deletion succeeded; provider preview required an idempotent retry |
The delivered fax took 44 seconds. Backend state matched the page: the send was billed through the review grant, all three pages were delivered, SignalWire independently reported three received pages, and the one-use grant was consumed once.
The test succeeded as a fax test. It also produced four concrete implementation problems.
Add a same-origin fallback for browser uploads
The browser builds the provider-bound PDF only when the user moves into the payment step. PromptFax then creates a signed GCS upload URL and sends the rebuilt PDF directly from the browser to storage.
This normally avoids routing a large document through the application server. In the OpenAI browser, the
OPTIONS request succeeded and returned the expected origin, methods, and allowed headers. The
actual PUT still failed before JavaScript received an HTTP response.
CORS preflight success proves that the server answered a permission question. It does not prove that the browser environment will complete the following cross-origin upload.
The fix keeps the signed upload as the fast path and adds a session-bound same-origin fallback. If the signed request rejects or returns a non-success response, the browser sends sequential 5 MiB chunks to PromptFax. The server composes those staging objects into the canonical GCS object, downloads the result for validation, checks the byte count and PDF page count, computes its hash, and only then associates it with the web session.
The browser-side control flow is deliberately simple:
let directUpload;
try {
directUpload = await fetch(uploadSession.uploadUrl, {
method: "PUT",
headers: uploadSession.headers,
body: rebuiltPdf
});
} catch (error) {
directUpload = null;
}
if (directUpload?.ok) {
await associateUploadedDocument(uploadSession.documentId);
} else {
await uploadSameOriginChunks(uploadSession, rebuiltPdf);
await completeAndValidateUpload(uploadSession.documentId);
}
Each chunk has a deterministic index and exact expected size. PromptFax rejects chunks that belong to another web session, exceed the document's expected range, or have the wrong byte count. Retrying a chunk overwrites the same staging object, so a network retry does not append duplicate bytes. Abort and failure paths delete both staging objects and any incomplete destination object.
The largest local test used the existing 35-page fixture. The browser rebuilt a 48,899,096-byte PDF, forced the direct upload to fail, transferred 10 chunks, and verified the composed object as a 35-page PDF. This is not proof that OpenAI's production browser now works. It is evidence that PromptFax's fallback handles the product's maximum document class before another live run.
Wait for the result the user can actually see
The first begin_checkout implementation called the normal payment handler and immediately
returned a success sentence. That was technically describing what the code had started. It was not
describing what the user could see.
Document synchronization and Stripe Embedded Checkout initialization are asynchronous. Either can fail after the click handler returns. An agent that sees "Opened the Stripe payment overlay" reasonably assumes the payment surface exists. In this test, it did not.
The revised contract waits for the normal workflow handler and checks the resulting overlay state:
const result = await handlePaymentClick({ throwOnError: true });
if (!result.opened) {
const detail = result.error ?? result.reason ?? "checkout did not become ready";
throw new Error(`Couldn't open checkout: ${detail}.`);
}
return {
quotedAmountCents: result.quotedAmountCents,
checkoutAmountCents: result.checkoutAmountCents,
status: "ready"
};
The payment handler now propagates synchronization and Stripe initialization failures to WebMCP while preserving the visible error state for a person using the page. The tool only reports success when the overlay is mounted and ready.
I also separated two amounts that the original tool collapsed:
- Quoted fax amount
- The normal PromptFax price, $2.00 in this test.
- Checkout total
- The amount Stripe asked the user to authorize after discounts, $0 for the review grant.
Both were correct. Calling both of them "the amount" made the tool response misleading.
Persist every state a tool can change
The reload after the upload workaround preserved the server-bound document but lost the destination. That exposed a state boundary that had been easy to miss while testing in one uninterrupted tab.
PromptFax actually had three relevant layers:
| Layer | Examples | Lifetime and owner |
|---|---|---|
| Browser-local editing | Selected pages, page order, thumbnails | Same browser, persisted locally for up to 24 hours |
| PromptFax web session | Document ID, destination, send-intent pointer | Short-lived server session |
| Provider-backed records | Stripe authorization, fax attempts, delivery state | Authoritative backend workflow |
The WebMCP destination tool initially updated only the live page. It now writes through a session-bound destination endpoint. Reload and hydration return the same normalized destination.
That change needed one important exception. PromptFax lets a user correct the destination after certain retryable fax failures while reusing the existing Stripe authorization. A naive destination update would clear the failed send-intent pointer and silently turn the retry into a new order. The server therefore preserves a failed send intent when its authorization and retry window are still reusable, while continuing to block destination changes after authorization or during active transmission.
Persisting state is not enough. It has to preserve the workflow's billing and retry invariants.
Make cleanup idempotent and retryable
After delivery, PromptFax materializes a delivery report in IndexedDB and removes short-lived remote dependencies. In the production test, document deletion completed, but clearing the stored provider preview URL did not. The original browser call was fire-and-forget, so the page had no automatic recovery path.
The finalizer now treats cleanup as several idempotent operations:
- Delete the remote document if it still exists.
- Clear provider preview metadata if it is still present.
- Clear the web session's active document and send-intent pointers.
- Return success only when every remaining step completes.
The browser marks the local report as remotely finalized only after that success. Failed calls retry after 2, 10, 30, and 120 seconds. A restored browser session also scans IndexedDB for delivery reports that still need finalization and resumes the process.
This is not a distributed transaction. It is a small idempotent reconciliation loop, which is a better match for independently failing storage, database, and provider-cleanup operations.
Test ChatGPT's API subset and your application's boundaries
The post-fix branch passed:
- The full deterministic pre-deployment gate: typecheck, unit tests, build, and Chromium smoke tests.
- 30 focused tests for upload fallback, destination persistence, checkout truthfulness, review-grant amounts, and cleanup retry behavior.
- The complete Playwright suite: 114 passed and 4 intentionally skipped.
- The MCP and widget browser suite: 27 passed.
- A forced direct-upload failure with the 35-page, 48.9 MB stress fixture, completed as 10 same-origin chunks.
- Browser tests where the first cleanup attempt fails and a later retry succeeds.
Those tests cover the implementation and its local browser behavior. They do not replace another real OpenAI built-in-browser test. The next production validation should repeat the original bounded flow with a one-use grant and synthetic receiver, then verify that:
- the browser selects the fallback automatically after the signed upload fails;
begin_checkoutdoes not return until Stripe is actually visible;- the tool reports the $2 quote and $0 Checkout total separately;
- destination survives reload;
- delivery cleanup finishes without manual intervention.
That distinction is worth making because "end-to-end" can mean very different things. I ran a full local browser E2E suite after the fix. I did not send a second live fax through OpenAI after the fix.
Keep human controls in the page
This test made me more positive about WebMCP, not less.
The agent was much better at reading workflow state, attaching a known URL, setting the destination, and polling delivery through structured tools than it would have been through screenshots and simulated clicks. The page remained visible, and the same interface still owned document review and payment.
That is the right shape for PromptFax. WebMCP did not replace the website or the backend MCP server. It gave a browser agent a precise way to cooperate with the website.
The limitations are equally concrete:
- WebMCP does not remove browser networking policy.
- A structured tool can still lie accidentally if it reports initiation as completion.
- Live-page state needs explicit persistence before it can survive navigation or reload.
- Real-world workflows need recovery after the visible task appears finished.
- Sensitive actions still need normal authorization and confirmation boundaries.
The API made actuation more reliable. It did not make the surrounding system simple.
Implementation checklist
Registering eight tools took little code. Making those tools truthful at the boundaries took most of the work.
The first production test found exactly the kinds of failures a local tool demo was unlikely to find: a browser-specific cross-origin upload rejection, an optimistic asynchronous result, a state value that existed only in one tab, and partial cleanup across independently failing systems.
Based on this implementation, my checklist for adding ChatGPT WebMCP support is:
- Register imperative tools in the top-level page, not inside an iframe.
- Wrap existing application handlers so people and agents use the same workflow and validation.
- Keep inputs narrow and describe side effects in the tool definition.
- Keep payment, sending, deletion, and other consequential actions visible and human-controlled.
- Persist state before a successful tool result claims the change will survive.
- Keep efficient cross-origin transfers when they work, but provide a bounded same-origin fallback.
- Return success when the user-visible state is ready, not when asynchronous work merely starts.
- Reconcile cleanup until every idempotent step is done.
- Run deterministic local tests and a bounded test in ChatGPT's actual built-in browser.
WebMCP worked. The upload did not. That was the useful part of the test.