Skip to content

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.

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 theme
synapse.resize(); // report size to the host
synapse.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.

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).

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.

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 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.