MCP App Bridge
The MCP App Bridge is NimbleBrain’s implementation of the ext-apps specification (2026-01-26) — the standard protocol that enables MCP Apps to render inside host applications with bidirectional tool access. NimbleBrain extends the spec with synapse/ namespace methods for data-change notifications, semantic actions, and state persistence.
All messages use window.postMessage with JSON-RPC 2.0 envelopes:
{ jsonrpc: "2.0", method: string, id?: string, // Present for requests that expect a response params?: object, result?: unknown, // Present in responses error?: object, // Present in error responses}Message overview
Section titled “Message overview”Spec messages (ext-apps)
Section titled “Spec messages (ext-apps)”Host to App
Section titled “Host to App”| Method | Type | Description |
|---|---|---|
ui/initialize | Response | Host responds to the app’s init request with capabilities, theme, and context. |
ui/notifications/tool-result | Notification | Forwarded tool result from the agent. |
ui/notifications/tool-input | Notification | Tool arguments being sent to a tool on the app’s server. |
ui/notifications/host-context-changed | Notification | Host context changed (theme toggle, locale, etc.). |
App to Host
Section titled “App to Host”| Method | Type | Description |
|---|---|---|
ui/initialize | Request | App initiates the handshake. Host responds with capabilities and context. |
ui/notifications/initialized | Notification | App confirms handshake complete. Host must not send tool data before this. |
ui/notifications/size-changed | Notification | Report content dimensions for auto-sizing inline views. |
ui/notifications/request-teardown | Notification | App requests the host to tear down the iframe. Currently a no-op. |
tools/call | Request | Call a tool on the app’s MCP server. Returns CallToolResult. |
ui/message | Request | Send a message to the conversation. |
ui/open-link | Request | Open a URL in a new browser tab. |
ui/update-model-context | Request | Push structured state visible to the LLM. See below. |
NimbleBrain extensions (synapse/)
Section titled “NimbleBrain extensions (synapse/)”These have no spec equivalent and degrade to no-ops in non-NimbleBrain hosts.
| Method | Direction | Description |
|---|---|---|
synapse/data-changed | Host → App | Data changed on a server — the app should refresh. |
synapse/action | App → Host | Request a semantic action from the shell. |
synapse/download-file | App → Host | Trigger a file download in the browser. |
synapse/persist-state | App → Host | Persist widget state across sessions. |
synapse/state-loaded | Host → App | Load previously persisted widget state. |
synapse/request-file | App → Host | Open native file picker. |
synapse/keydown | App → Host | Forward keyboard shortcuts to the host. |
Initialization handshake
Section titled “Initialization handshake”The app initiates the handshake per the ext-apps spec:
- App → Host:
ui/initializerequest withappInfo,appCapabilities,protocolVersion - Host → App: Response with
hostInfo,hostCapabilities,hostContext - App → Host:
ui/notifications/initializednotification - Host sends tool data only after receiving
initialized
// 1. App sends{ "jsonrpc": "2.0", "method": "ui/initialize", "id": "syn-1", "params": { "protocolVersion": "2026-01-26", "appInfo": { "name": "my-app", "version": "1.0.0" }, "appCapabilities": {} }}
// 2. Host responds{ "jsonrpc": "2.0", "id": "syn-1", "result": { "protocolVersion": "2026-01-26", "hostInfo": { "name": "nimblebrain", "version": "1.0.0" }, "hostCapabilities": { "openLinks": {}, "serverTools": {}, "logging": {} }, "hostContext": { "theme": "dark", "styles": { "variables": { "--color-background-primary": "#0f172a", "--color-text-primary": "#e2e8f0" } } } }}
// 3. App sends{ "jsonrpc": "2.0", "method": "ui/notifications/initialized", "params": {}}Host to App messages
Section titled “Host to App messages”ui/notifications/tool-result
Section titled “ui/notifications/tool-result”Sent when the agent calls one of your app’s tools during a conversation. The params follow the standard MCP CallToolResult shape.
{ "jsonrpc": "2.0", "method": "ui/notifications/tool-result", "params": { "content": [{ "type": "text", "text": "{\"temp\":72,\"condition\":\"sunny\"}" }], "structuredContent": { "temp": 72, "condition": "sunny" } }}ui/notifications/host-context-changed
Section titled “ui/notifications/host-context-changed”Sent when the user toggles themes, changes locale, or other host context changes. Theme tokens are nested under styles.variables.
{ "jsonrpc": "2.0", "method": "ui/notifications/host-context-changed", "params": { "theme": "dark", "styles": { "variables": { "--color-background-primary": "#0f172a", "--color-text-primary": "#e2e8f0" } } }}ui/notifications/tool-input
Section titled “ui/notifications/tool-input”Sent when the agent is calling a tool on your app’s server. The params contain the tool arguments. Use this to show a loading state or preview the incoming action.
{ "jsonrpc": "2.0", "method": "ui/notifications/tool-input", "params": { "arguments": { "city": "Honolulu", "days": 5 } }}synapse/data-changed
Section titled “synapse/data-changed”Sent when a tool call completes on a server, indicating the app’s data may have changed. Use this to refresh your UI.
{ "jsonrpc": "2.0", "method": "synapse/data-changed", "params": { "source": "agent", "server": "weather", "tool": "set_location" }}App to Host messages
Section titled “App to Host messages”tools/call
Section titled “tools/call”Call a tool on your app’s MCP server. This is a JSON-RPC request — the response is a standard MCP CallToolResult.
Request:
{ "jsonrpc": "2.0", "method": "tools/call", "id": "call-1", "params": { "name": "get_weather", "arguments": { "city": "Honolulu" } }}Success response (CallToolResult):
{ "jsonrpc": "2.0", "id": "call-1", "result": { "content": [{ "type": "text", "text": "{\"temp\":82,\"condition\":\"partly cloudy\"}" }], "structuredContent": { "temp": 82, "condition": "partly cloudy" } }}Error response:
{ "jsonrpc": "2.0", "id": "call-1", "error": { "code": -32000, "message": "City not found" }}ui/message
Section titled “ui/message”Send a message to the conversation. Follows the ext-apps spec format with role and content array. Context can be attached via _meta on the content block.
{ "jsonrpc": "2.0", "method": "ui/message", "params": { "role": "user", "content": [{ "type": "text", "text": "Show me the 5-day forecast for Honolulu", "_meta": { "context": { "action": "forecast", "entity": { "type": "city", "id": "honolulu" } } } }] }}The _meta.context object is optional. When present, it helps the agent understand the source and intent of the message.
Prompt suggestion (NimbleBrain extension — pre-fills the chat input without sending):
{ "jsonrpc": "2.0", "method": "ui/message", "params": { "action": "prompt", "value": "What's the weather forecast for this week?" }}ui/open-link
Section titled “ui/open-link”Open a URL in a new browser tab with noopener.
{ "jsonrpc": "2.0", "method": "ui/open-link", "params": { "url": "https://weather.gov/forecast" }}ui/notifications/size-changed
Section titled “ui/notifications/size-changed”Report content dimensions so the host can auto-size inline views.
{ "jsonrpc": "2.0", "method": "ui/notifications/size-changed", "params": { "width": 800, "height": 480 }}ui/update-model-context
Section titled “ui/update-model-context”Push structured state into the agent’s context so the LLM knows what the user is viewing. This is an ext-apps spec method — when the user sends a chat message, this state is included in the system prompt.
{ "jsonrpc": "2.0", "method": "ui/update-model-context", "id": "ctx-1", "params": { "structuredContent": { "view": "board", "filter": "overdue", "selectedTasks": ["tsk_01", "tsk_02"] }, "summary": "User is viewing the board with overdue filter, 2 tasks selected" }}The summary field is optional but recommended — it’s used as a fallback when the full state exceeds the token budget. If the message includes an id, the host responds with an empty result.
synapse/download-file
Section titled “synapse/download-file”Trigger a file download. The data field is the file content as a string.
{ "jsonrpc": "2.0", "method": "synapse/download-file", "params": { "data": "city,temp,condition\nHonolulu,82,sunny\nNew York,45,cloudy", "filename": "weather-report.csv", "mimeType": "text/csv" }}synapse/action
Section titled “synapse/action”Request a semantic action from the shell. Your app declares intent and the shell resolves it — no need to know routes or layout details.
{ "jsonrpc": "2.0", "method": "synapse/action", "params": { "action": "openConversation", "id": "conv_abc123" }}The action field is required. All other fields in params are action-specific.
Built-in actions (handled by the bridge):
| Action | Params | Description |
|---|---|---|
navigate | { route: string } | Navigate to a route in the shell (e.g., /app/tasks). Invokes the onNavigate callback. |
Consumer-level actions (handled by shell callbacks):
| Action | Params | Description |
|---|---|---|
openConversation | { id: string } | Open the chat panel and load a specific conversation. |
startChat | { prompt?: string } | Open the chat panel. Optionally pre-fill a prompt. |
openApp | { name: string } | Navigate to an installed app by server name. |
The bridge handles navigate directly. All other actions are dispatched to the registered onAction callback, or as an nb:action custom event on window if no callback is registered.
Security model
Section titled “Security model”The bridge enforces two security boundaries:
- Origin isolation — The host only processes messages from the iframe’s
contentWindow. Messages from other sources are silently dropped. - Tool scoping —
tools/callrequests are always routed to the app’s own MCP server. An iframe cannot call tools on another app’s server.
Example: making a tool call from an iframe
Section titled “Example: making a tool call from an iframe”<!DOCTYPE html><html><head><meta charset="utf-8"></head><body> <button id="btn" disabled>Get Weather</button> <pre id="output"></pre>
<script> let initialized = false;
window.addEventListener('message', (event) => { const msg = event.data; if (!msg || msg.jsonrpc !== '2.0') return;
// Host responds to our ui/initialize request if (msg.id === 'init-1' && msg.result) { initialized = true; // Send initialized notification window.parent.postMessage({ jsonrpc: '2.0', method: 'ui/notifications/initialized', params: {} }, '*'); document.getElementById('btn').disabled = false; }
// Tool call response (CallToolResult) if (msg.id === 'weather-1' && msg.result) { const data = msg.result.structuredContent || msg.result; document.getElementById('output').textContent = JSON.stringify(data, null, 2); }
if (msg.id === 'weather-1' && msg.error) { document.getElementById('output').textContent = 'Error: ' + msg.error.message; } });
// Initiate handshake window.parent.postMessage({ jsonrpc: '2.0', method: 'ui/initialize', id: 'init-1', params: { protocolVersion: '2026-01-26', appInfo: { name: 'weather-demo', version: '1.0.0' }, appCapabilities: {} } }, '*');
// Send a tool call when the button is clicked document.getElementById('btn').addEventListener('click', () => { window.parent.postMessage({ jsonrpc: '2.0', method: 'tools/call', id: 'weather-1', params: { name: 'get_weather', arguments: { city: 'Honolulu' } } }, '*'); }); </script></body></html>Bridge runtime helper
Section titled “Bridge runtime helper”NimbleBrain’s core resources include a bridge runtime script that automatically handles theme synchronization. It listens for ui/initialize (legacy notification path) and ui/notifications/host-context-changed (spec) and applies tokens as CSS custom properties.
If you are building a third-party app, you can implement the same pattern:
window.addEventListener('message', (event) => { const msg = event.data; if (!msg || typeof msg !== 'object' || !msg.method) return;
// Apply theme tokens from host-context-changed (spec) if (msg.method === 'ui/notifications/host-context-changed') { const vars = msg.params?.styles?.variables; if (vars) applyTokens(vars); }
// Apply theme tokens from legacy ui/initialize notification if (msg.method === 'ui/initialize' && msg.params?.theme?.tokens) { applyTokens(msg.params.theme.tokens); }});
function applyTokens(tokens) { for (const [key, value] of Object.entries(tokens)) { document.documentElement.style.setProperty(key, value); }}