Skip to content

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
}
MethodTypeDescription
ui/initializeResponseHost responds to the app’s init request with capabilities, theme, and context.
ui/notifications/tool-resultNotificationForwarded tool result from the agent.
ui/notifications/tool-inputNotificationTool arguments being sent to a tool on the app’s server.
ui/notifications/host-context-changedNotificationHost context changed (theme toggle, locale, etc.).
MethodTypeDescription
ui/initializeRequestApp initiates the handshake. Host responds with capabilities and context.
ui/notifications/initializedNotificationApp confirms handshake complete. Host must not send tool data before this.
ui/notifications/size-changedNotificationReport content dimensions for auto-sizing inline views.
ui/notifications/request-teardownNotificationApp requests the host to tear down the iframe. Currently a no-op.
tools/callRequestCall a tool on the app’s MCP server. Returns CallToolResult.
ui/messageRequestSend a message to the conversation.
ui/open-linkRequestOpen a URL in a new browser tab.
ui/update-model-contextRequestPush structured state visible to the LLM. See below.

These have no spec equivalent and degrade to no-ops in non-NimbleBrain hosts.

MethodDirectionDescription
synapse/data-changedHost → AppData changed on a server — the app should refresh.
synapse/actionApp → HostRequest a semantic action from the shell.
synapse/download-fileApp → HostTrigger a file download in the browser.
synapse/persist-stateApp → HostPersist widget state across sessions.
synapse/state-loadedHost → AppLoad previously persisted widget state.
synapse/request-fileApp → HostOpen native file picker.
synapse/keydownApp → HostForward keyboard shortcuts to the host.

The app initiates the handshake per the ext-apps spec:

  1. App → Host: ui/initialize request with appInfo, appCapabilities, protocolVersion
  2. Host → App: Response with hostInfo, hostCapabilities, hostContext
  3. App → Host: ui/notifications/initialized notification
  4. 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": {}
}

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" }
}
}

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"
}
}
}
}

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

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"
}
}

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"
}
}

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?"
}
}

Open a URL in a new browser tab with noopener.

{
"jsonrpc": "2.0",
"method": "ui/open-link",
"params": {
"url": "https://weather.gov/forecast"
}
}

Report content dimensions so the host can auto-size inline views.

{
"jsonrpc": "2.0",
"method": "ui/notifications/size-changed",
"params": {
"width": 800,
"height": 480
}
}

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.

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"
}
}

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

ActionParamsDescription
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):

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

The bridge enforces two security boundaries:

  1. Origin isolation — The host only processes messages from the iframe’s contentWindow. Messages from other sources are silently dropped.
  2. Tool scopingtools/call requests 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>

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);
}
}