Cross-host architecture
ChatGPT, Claude, and a host you run yourself all speak the MCP Apps standard (SEP-1865) to an embedded UI. A standalone page has no host at all. Synapse lets you write one component that runs in all of them, without branching on the host in your app code.
This is a distinct path from the ext-apps connect() client.
It is push-first and deliberately small, meant for a self-contained ui://
component that inlines its own client. The entry point is connectUI, and it is
additive: the ext-apps paths are unchanged by it.
The façade
Section titled “The façade”Your app codes against one small surface and never learns which host it landed in:
const synapse = connectUI();
synapse.onData((data) => render(data));synapse.theme(); // current themesynapse.resize(); // report size to the hostsynapse.openLink(url);synapse.sendPrompt(text);await synapse.callTool(name, args);data(), onData(), theme(), resize(), openLink(), sendPrompt(), and
callTool() are the whole contract. What changes underneath is the adapter.
The adapter model
Section titled “The adapter model”Behind the façade sits one adapter per bridge, and there are two.
| Host or context | Bridge | Adapter |
|---|---|---|
| ChatGPT, Claude, NimbleBrain | MCP Apps standard (SEP-1865) | mcpapps |
| Standalone | none | inline |
mcpapps implements the ui/* JSON-RPC bridge (ui/initialize then
ui/notifications/initialized, then tool-result and host-context-changed, plus
size-changed, ui/open-link, ui/message, and tools/call as a pull). It sends
nothing before ui/initialize, because a strict host drops an early frame and
leaves the component hidden. A tool result reporting isError: true rejects
callTool() with a ToolCallError: MCP reports a failed or refused call inside
the result, and ChatGPT reports a refusal that way.
inline is the standalone adapter. With no host bridge, it renders the component
against neutral fallbacks so a component still works in isolation (a static render,
a test, a preview).
Detection
Section titled “Detection”At startup the client checks whether its document is framed: a frame selects
mcpapps, a top-level document selects inline. Nothing on the window says which
product the frame belongs to. window.openai in particular does not, because
ChatGPT injects it into every frame it serves. The host names itself in the
handshake. Apps never change, because they only see the façade.
Why it is decoupled from the MCP SDK
Section titled “Why it is decoupled from the MCP SDK”The cross-host client is hand-spoken JSON-RPC over postMessage. It
does not depend on @modelcontextprotocol/*, and specifically it avoids pulling in
Zod. That keeps the window.SynapseUI IIFE that a self-contained ui:// component
inlines small, which matters when the whole component ships as one inlined asset.
One rule falls out of the shared adapters: keep NimbleBrain-private ai.nimblebrain/*
fields out of the mcpapps payloads. And remember the handshake
gotcha from the protocol page: a host keeps the iframe
hidden until it gets ui/initialize and a size report, so call resize().
The server half
Section titled “The server half”The component’s server side is the Python
nimblebrain-synapse package. From one SynapseUI
declaration — handed to the server as an MCP extension — it contributes the
component as one ui:// resource under the MCP Apps MIME, text/html;profile=mcp-app,
which ChatGPT and Claude both render. Plain MCP clients that render no UI still read
the tool’s structuredContent.
Related
Section titled “Related”- Render one component across hosts: the how-to for
connectUI. - The portable-app contract: what each call does on a host that lacks a capability.
- Quickstart: Python server: declaring the
ui://component on a Python MCP server.