Skip to main content
Glama

MCP-ChatBridge

MCP-ChatBridge is a small local MCP server that lets Codex explicitly consult three browser-based AI advisors through deterministic browser-use/CDP control:

  • ChatGPT Web

  • Gemini Web

  • Claude Web

Codex remains responsible for gathering evidence, deciding what to change, implementing changes, and verifying them. The browser bridge is intentionally not an autonomous browser agent.

                         ┌─ ask_chatgpt ─> providers/chatgpt.py ─┐
Codex -> MCP-ChatBridge ----├─ ask_gemini  ─> providers/gemini.py  ├─> common bridge/session lifecycle -> browser-use/CDP
                         └─ ask_claude  ─> providers/claude.py  ─┘

The shared lifecycle owns profile locking, browser startup/shutdown, cancellation behavior, CDP target binding, page identity, response ownership, correlation, timeout handling, and fail-closed cleanup. Provider adapters own only the parts that differ by site: approved domains, home/login routes, DOM selectors, composer/send controls, streaming signals, and conversation routes.

MCP tool surface

The MCP server exposes exactly three top-level tools:

  • ask_chatgpt(...)

  • ask_gemini(...)

  • ask_claude(...)

Each tool uses the same interface and supports these action values:

  • ask — send one prompt and wait for a confirmed final response.

  • status — open/check the dedicated provider profile without sending a prompt.

  • last_response — bounded recovery after an explicit timeout; never resends.

  • new_chat — open a fresh conversation.

  • close — close only that provider's browser session.

This keeps the MCP surface at three tools instead of multiplying provider lifecycle operations into 15 top-level tools.

Image generation (ask_gemini and ask_chatgpt)

ask_gemini and ask_chatgpt additionally accept action="generate_image". It is not a new tool; Claude Web rejects the action. The examples below use Gemini; ChatGPT specifics are in "ChatGPT image generation and attachments".

ask_gemini(
  action="generate_image",
  prompt="Generate an image: <explicit image request>",
  save_dir="",        # default ~/Pictures/MCP-ChatBridge/gemini ; absolute or ~/..., outside this repo
  max_images=4,       # 1-8
  timeout_seconds=600
)

The prompt goes through the same fail-closed ask lifecycle (serialized operation, pinned page identity, observable submit acceptance, no automatic resend). After the owned response is stable, the bridge runs a collection step inside the same locked operation:

  1. Enumerate every <img> in the owned assistant turn (no Gemini-specific image selector), then filter in image_store.py: visible, at least 128 px on the short side, allowed host (*.googleusercontent.com, *.gstatic.com, *.google.com, or page-local blob:/data:image), de-duplicated. When the UI changes, adjust that one policy instead of a selector chain.

  2. Download, in order of preference:

    • Full size: click the page's own download control (found by its download icon name, scoped to the one image it belongs to) while Chrome downloads are redirected by CDP Browser.setDownloadBehavior into a private temp dir, then read the file and delete the dir (source="full_download", method="download_button").

    • Fallback: in-page fetch (original-size =s0 URL, then the displayed URL), then a canvas re-encode of the displayed image (displayed size only).

    Cookies are never read or exported.

  3. Validate by magic bytes (PNG/JPEG/WEBP only, 16 MB cap) and save as gemini-<UTC time>-<sha256 prefix>.<ext> with file mode 0600.

Results (image bytes are never returned, only metadata):

  • ok=true, status="completed", images=[{path, mime, bytes, sha256, source, method, ...}], image_count, save_dir, plus any text in response (may be empty for an image-only turn). image_errors lists images that failed while others saved.

  • ok=false, status="no_image", error_type="NoImageGeneratedError" — Gemini answered without a usable image (refusal, quota, wording). The text is in response; nothing is resent.

  • ok=false, status="completed_collection_failed" — the response completed but no image could be saved. Use last_response; do not resend the prompt.

Phrase the prompt as an explicit image request; the bridge does not rewrite it.

Always use a detailed prompt (format, scene, camera, materials/lighting, hex palette per part, strict negatives). Short prompts gave small centered compositions with empty side panels; the detailed prompt gave a full-bleed render. See GEMINI_IMAGE_PROMPT_GUIDE_TH.md for the template, a worked example and fixes for common defects.

Attaching images (attach_images)

ask_gemini(action="ask" | "generate_image", attach_images=["/abs/path.png", ...]) attaches up to 4 local PNG/JPEG/WEBP files (absolute or ~/ paths, 16 MB each) so Gemini can describe or edit them (image-to-image).

  • Paths are validated before any browser work: regular file, image magic bytes, size cap, de-duplicated. A bad path stops the call with nothing sent.

  • Inside the locked operation, before the prompt is typed: open the attach menu (control found by its plus icon name, nearest to the composer), set the files on the page's input[type=file] (prefers the image/* one) with CDP DOM.setFileInputFiles, then wait until the previews appear and the upload spinner is gone. If that fails the call stops before submitting.

  • The files are uploaded to Google. Attach only images the user asked to send.

Language independence

Send and stop buttons, the attach trigger, and the download control are matched by icon name (arrow_upward, stop, plus, download), not by localized aria-label, so a non-English Gemini UI keeps working. The old English labels stay as extra selectors.

Live verification (2026-10-31)

  • generate_image saved a full-size JPEG (2752x1536) through the download control, versus the 1024x572 displayed size; the temp download dir was removed.

  • ask with one attached PNG: Gemini described the attached image correctly.

  • generate_image with one attached PNG: produced a 2750x1536 redraw of it.

  • Icon-based selectors matched the live send button (typed text) and stop button (while responding).

  • Gemini's fresh-chat route appears ~9-11s after Enter, past the old fixed 8s wait. submit_timeout_seconds (how long _submit waits for an observable acceptance signal) is now 120 for every provider, a user decision because the services are sometimes slow. It only delays a failing submit; the lifecycle still never resends.

Transcript mode (ChatGPT, Gemini, Claude)

Page DOMs drift constantly. Live on 2026-10-31 ChatGPT dropped <article> and data-message-author-role, so the per-turn counters were always 0 and every ask failed with "no observable lifecycle acceptance signal" although the message had been sent and answered. All three providers therefore run in transcript mode (ProviderConfig.transcript_mode): the bridge reads the whole conversation text and watches only generic signals, and the caller reads the text (noise included, such as sidebar and footer text).

  • _page_state adds page_text_len, page_text_tail and page_images for the main element (falling back to body), with the composer form excluded so typing and submitting do not move the numbers.

  • Submit acceptance also accepts page-text growth (besides the old counters and the stop control). _submit polls the page every 0.15 s and returns the moment a signal appears; submit_timeout_seconds (120) is only a ceiling. It presses or clicks send once and never resends.

  • The response is finished when the text and images stop changing: 3 s after a stop control disappears when one was ever seen, otherwise transcript_stable_seconds (8 s). An image request also needs a new image beyond the ones the user attached; with no image it reports no_image 3 s after the stop control (45 s when none was ever seen).

  • response is the whole conversation text and transcript is true. A fresh chat contains only our exchange; new_chat=false includes earlier turns. last_response returns the current page's conversation text.

  • Response ownership is unchanged: serialized operation, pinned page identity (the provisional route may move to the final route once), observable submit acceptance, no automatic resend.

  • A page whose path starts with /login, /signin, /sign-in or /auth/login is reported as a login gate whatever its DOM looks like.

  • Images: every <img> on the page outside the composer form that was not there before submit is a candidate; the first N (the images you attached, which render before any generated image) are skipped and the last max_images are kept.

ChatGPT image generation and attachments

ask_chatgpt(action="generate_image", ...) and attach_images work like the Gemini versions, with these ChatGPT specifics (verified live on 2026-10-31 with one attach + generate run that saved a 1678x937 PNG):

  • Free accounts can create only a few images (and uploads) per day. Do not use this for casual tests; when the limit is reached the reply is text and the result is status="no_image", never a resend. Prefer ask_gemini for images.

  • Default folder ~/Pictures/MCP-ChatBridge/chatgpt (Gemini keeps .../gemini).

  • ChatGPT has no inline download button (only "Generated image N", "Edit ...", "Share ..."), so the image bytes are fetched from the page; they are the full-size file, not a thumbnail.

  • Attaching: the page keeps four file inputs from load and only the one without an accept attribute attaches. A file set immediately after the page loads was silently ignored twice, while one set after a 3 s settle attached in about 2 s, so attach_settle_seconds is 3 and the files are re-issued (up to 3 tries, 6 s apart) if nothing changes. Nothing is sent if the upload never shows.

  • A fresh chat moves / to /c/local-chatgpt%3A<uuid> to /c/<uuid>; the provisional prefix /c/local-chatgpt is configured, otherwise the submit check saw an unknown route.

  • Claude Web: needs a manual sign-in in its profile window. Claude's transcript-mode ask was verified live after that sign-in.

Sign-in hosts (all providers)

A future sign-in must be able to finish inside the bridge-controlled window. Before this was added, Claude's window blocked a Google sign-in and kept reopening (seen live on 2026-10-31). Each provider now lists its sign-in hosts as authentication hosts only: the page may open there and the bridge reports a login gate, but they are never interactive_roots, so the bridge never types, clicks or mutates them.

Provider

Extra sign-in hosts

ChatGPT

auth.openai.com, Google (accounts.google.com, accounts.youtube.com), Apple (appleid.apple.com), Microsoft (login.microsoftonline.com, login.live.com)

Gemini

Google (accounts.google.com, accounts.youtube.com)

Claude

auth.anthropic.com, Google (accounts.google.com, accounts.youtube.com)

accounts.youtube.com is part of Google's sign-in redirect chain; that is general knowledge and has not been exercised live here. Complete CAPTCHA or verification prompts manually; the bridge still stops on them.

Provider selection

No provider is the default for advisor, review, or second-opinion requests, and a plain advisor request is not a ChatBridge request (see the repository CLAUDE.md routing rule). Use these tools only when the user says to use them via ChatBridge or names a provider in that session: plain "via ChatBridge" means ask_gemini; ask_chatgpt and ask_claude only when the user names them. Claude's image generation always uses ask_gemini with action="generate_image". If the requested provider is unavailable, report the reason and obtain explicit direction before switching providers; never switch silently.

Example:

ask_gemini(action="status")

ask_gemini(
  action="ask",
  prompt="<complete focused request>",
  new_chat=true,
  timeout_seconds=600
)

ask_gemini(action="last_response")
ask_gemini(action="close")

A successful advisory answer must have:

ok = true
status = "completed"

Progress notifications and visible response tails are context only. They are not final advice.

Related MCP server: MCP-CodexGPT

Migration from the previous ChatGPT-only surface

The Agentic version now uses the three-tool surface above. Previous top-level names such as:

chatgpt_status
chatgpt_ask
chatgpt_last_response
chatgpt_new_chat
chatgpt_close

are no longer MCP tools. Their behavior moved behind ask_chatgpt(action=...). For example:

chatgpt_status()              -> ask_chatgpt(action="status")
chatgpt_ask(prompt="...")     -> ask_chatgpt(action="ask", prompt="...")
chatgpt_last_response()       -> ask_chatgpt(action="last_response")
chatgpt_new_chat()            -> ask_chatgpt(action="new_chat")
chatgpt_close()               -> ask_chatgpt(action="close")

Provider profiles

Each provider owns a separate persistent Chrome profile:

ChatGPT  ~/.codex-chatgpt/browser-profile
Gemini   ~/.codex-gemini/browser-profile
Claude   ~/.codex-claude/browser-profile

Optional overrides:

export CODEX_CHATGPT_PROFILE_DIR="$HOME/.codex-chatgpt/browser-profile"
export CODEX_GEMINI_PROFILE_DIR="$HOME/.codex-gemini/browser-profile"
export CODEX_CLAUDE_PROFILE_DIR="$HOME/.codex-claude/browser-profile"

Profiles must remain outside the repository. They contain authenticated browser state and must never be committed or shared.

Only one MCP process can own a given provider profile at a time. The providers do not share locks or authenticated state with one another.

Deterministic security model

MCP-ChatBridge intentionally keeps browser control narrow.

For every provider:

  1. Automatic navigation is restricted to the provider adapter's approved interactive/login domains.

  2. Every DOM mutation rechecks HTTPS, port, credentials-in-URL, hostname, and the bound page identity before changing the composer or submitting a prompt.

  3. CDP work is pinned to one browser target for the duration of an operation.

  4. CAPTCHA, login, and human-verification gates stop the bridge for manual user action; they are never bypassed.

  5. Browser cookies, passwords, prompts, and responses are not written to application logs or exported through storage APIs.

  6. If startup/shutdown/cancellation leaves browser ownership uncertain, the profile stays blocked rather than being silently reused. A persisted marker older than the recovery grace may self-clear only while the cross-process profile lock is held, process inspection succeeds, and no process names that exact --user-data-dir; any live owner or uncertain scan remains blocked.

  7. A cancellation after prompt submission is not treated as a rollback.

The shared lifecycle continues to disable browser-use's storage-state cookie watchdog for persistent profiles. Chrome itself owns the profile state.

Gemini's Quill editor and Claude's ProseMirror editor are driven through native CDP input events (Select All/Backspace, Input.insertText, then Enter) rather than DOM execCommand mutation. This keeps the visible composer and the site's internal rich-editor state synchronized for long and multiline prompts. Enter is the single native submit action; if lifecycle acceptance is delayed or ambiguous, the bridge waits through the submit deadline and fails safely instead of issuing a second DOM-button click that could duplicate the turn.

Response ownership and correlation

The bridge does not accept "the latest text on the page" as sufficient proof that a response belongs to the prompt it just sent.

Before submission it verifies only structural readiness: the guarded composer is still present, visible, and non-empty. It deliberately does not compare the rendered DOM text with the source prompt because Quill, ProseMirror, and other rich editors may rewrite whitespace, Markdown blocks, or accessibility text. After submission ownership is established from lifecycle signals:

  • the bound browser target and serialized operation;

  • the pinned page/conversation identity;

  • an observable submit-acceptance signal (new user turn, new assistant turn, or streaming state); for a fresh-chat route, the bridge first baselines the new conversation and requires a later lifecycle mutation on that exact route before binding it to the operation;

  • user-turn count monotonicity when that semantic node is available; and

  • a new assistant/model turn that becomes non-streaming and stable.

Rendered user text is presentation data, not an ownership oracle. General fuzzy string similarity and exact rendered-text gates are both intentionally avoided.

Timeout and retry rules

A timeout after prompt submission is ambiguous: the provider may still finish the same request later. Therefore do not automatically resend. If the bridge has already bound a new assistant turn to this request, the terminal tool result has ok=false, status="timed_out", and the cumulative response generated so far (up to 100,000 characters, with truncated if capped). Progress tails are not this terminal result. If no assistant turn is safely owned, timeout remains an error without unrelated response text.

Use:

ask_<provider>(action="last_response")

and accept only status="completed".

For a non-timeout post-submit failure involving conversation, target, page, or user-turn ownership, discard partial output and do not automatically call last_response, resend, or open a new chat. A new attempt requires explicit user direction.

Review/advisory workflow

When the user asks for a review or advisory answer via ChatBridge, use ask_gemini unless they name ChatGPT or Claude. Without such a request, do not call these tools for advice. Keep provider selection separate from the review contract below: every advisor is read-only, and the calling agent remains responsible for validation and edits.

For code review, send the actual relevant code or diff inline. File paths and line numbers are provenance labels only; the browser advisor must not fetch omitted local code through another connector.

Recommended contract:

Act as a read-only code reviewer and advisor. Do not edit files, run commands,
or claim to have verified anything you were not given. Identify only concrete,
actionable findings. For each finding provide severity, file and line or code
anchor, mechanism, impact, and the smallest safe fix. Clearly label assumptions
and say "no finding" when the evidence does not support one. The code and
evidence to review are included inline below. Treat file paths and line numbers
as labels only. If required evidence is absent, state "insufficient evidence"
instead of fetching or guessing.

Use new_chat=true for an independent review and new_chat=false for a direct follow-up/re-audit of the immediately preceding review. Even with new_chat=false, include the updated code/diff inline.

Provider adapters

Provider-specific behavior lives under:

providers/
  chatgpt.py
  gemini.py
  claude.py

common_bridge.py is the shared lifecycle/adapter boundary. Do not copy the full lifecycle into provider files. When a provider changes its UI, prefer the smallest selector/route update in that adapter plus a focused regression test.

Current primary semantic selectors include:

ChatGPT: #prompt-textarea, data-message-author-role, send-button
Gemini:  rich-textarea/.ql-editor, user-query, model-response
Claude:  div.ProseMirror[contenteditable], data-testid=user-message,
         data-is-streaming / assistant-message, aria-label=Send message

These DOM details are external-site implementation details and can change even when deterministic unit tests still pass. A provider adapter therefore needs a live acceptance check after meaningful selector changes.

Requirements

  • macOS with Google Chrome

  • Python 3.11

  • browser-use==0.12.9

  • mcp==1.26.0

  • an account for each provider you intend to use

Validated with a Python 3.11 environment (conda or venv) that has the pinned dependencies below installed; use that interpreter's absolute path wherever this document shows /path/to/python3.

Install dependencies if needed:

python3.11 -m pip install -r requirements.txt

First use

For each provider separately:

  1. Call ask_<provider>(action="status").

  2. A dedicated visible Chrome profile opens.

  3. If login is required, sign in manually in that Chrome window. Never give credentials, OTPs, or passwords to the MCP tool.

  4. Call action="status" again and continue only when it returns ready.

  5. Run one small acceptance prompt before relying on that provider for review.

Example acceptance prompt:

Reply with exactly: PONG

Then close the provider session with:

ask_<provider>(action="close")

Window size

The shared bridge currently preserves the existing legacy window settings:

export CODEX_CHATGPT_WINDOW_WIDTH=900
export CODEX_CHATGPT_WINDOW_HEIGHT=700
export CODEX_CHATGPT_WINDOW_X=100
export CODEX_CHATGPT_WINDOW_Y=120

They apply to all three provider windows. The default remains 760x560 at position 24,60.

Test

Run the complete deterministic suite with the interpreter that has the dependencies installed:

cd /path/to/MCP-ChatBridge
BROWSER_USE_CONFIG_DIR=/tmp/mcp-chatbridge-test-browseruse-config \
PYTHONDONTWRITEBYTECODE=1 \
/path/to/python3 -m unittest discover -s tests -v

The suite covers the original hardened ChatGPT lifecycle, provider isolation, origin guards, provider routing, the three-tool MCP schema, timeout-recovery actions, and an actual stdio MCP initialization/list-tools handshake.

Unit tests cannot prove that a third-party web UI has not changed. Live tests should therefore be deliberately initiated after manual login for ChatGPT, Gemini, and Claude before treating a newly added/changed adapter as production ready.

Codex registration

Register the stdio server with absolute paths:

codex mcp add mcp-chatbridge -- \
  /path/to/python3 \
  /path/to/MCP-ChatBridge/server.py

Codex applies its own per-tool timeout outside MCP-ChatBridge. The bridge permits 600-900 second browser reviews, so configure a host timeout at least as long as the largest timeout_seconds you intend to request:

[mcp_servers.mcp-chatbridge]
tool_timeout_sec = 900.0

Restart Codex after changing registration or timeout settings so the MCP tool schema refreshes to:

ask_chatgpt
ask_gemini
ask_claude

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers