Skip to main content
All posts

MCP App Actions: callServerTool, sendMessage, and updateModelContext (September 2026)

Abe Wheeler
MCP AppsMCP App FrameworkMCP App TestingChatGPT AppsChatGPT App FrameworkChatGPT App TestingClaude ConnectorsClaude Connector FrameworkcallServerToolwindow.openai
MCP App actions route UI events through the host bridge so the app can call tools, update model context, and continue the conversation safely.

MCP App actions route UI events through the host bridge so the app can call tools, update model context, and continue the conversation safely.

Getting an MCP App UI on screen is the first step. The next question is what should happen when someone clicks a button inside it.

That choice affects security, model behavior, and portability. A normal React app can fetch('/api/save') from an event handler. An MCP App runs inside a host-owned iframe, so server tools, model context, chat messages, links, files, and display changes go through a negotiated host bridge. The event handler still starts in React, but it must choose the request that matches the user’s intent.

TL;DR: Use callServerTool when the UI needs backend work. Use updateModelContext when the model should know the latest UI state without replying yet. Use sendMessage when the user’s action should continue the conversation. Check the negotiated capability before each optional request, authorize every server call, prevent duplicate or stale writes, and keep an accessible fallback. For ChatGPT Apps, use the shared MCP Apps bridge first and treat window.openai methods as compatibility aliases or optional extensions.

Map Intent to One Action

The methods look similar because they all cross the iframe boundary, but they have different effects:

  • callServerTool runs backend code and returns an MCP tool result to the View.
  • updateModelContext replaces model-readable context for a future turn.
  • sendMessage adds a user-role message and may start model work now.
  • Host actions ask the host to open a link, change display mode, download a file, or perform another host-owned operation.

None of these methods lets the iframe reach the host DOM, cookies, or credentials. The View sends JSON-RPC over postMessage, then the host decides whether it supports and allows the request. That boundary is why capability checks and server-side authorization belong in the design, not only in error handling.

The Four Action Types

Most MCP App controls fall into four buckets.

UI intentApp APIProtocol methodImmediate effect
Fetch, save, validate, paginate, refreshcallServerTooltools/callReturns a tool result to the View
Tell the model what changedupdateModelContextui/update-model-contextReplaces context for a future model turn
Continue the conversationsendMessageui/messageAdds a user message and may start a response
Open a link, change display mode, download a fileHost action APIA matching ui/* requestRuns a host-owned action if supported

The mistake is treating all of these as “call a tool.” A filter change, a Save button, and a “ask the assistant to explain this” button have different side effects. Put them on different paths.

Use callServerTool for Backend Work

callServerTool is the MCP App equivalent of a UI-driven backend request, but it does not call your server directly from the iframe. The app asks the host to call a tool on the originating MCP server. The host proxies the call and returns the tool result.

Use it for:

  • Pagination and “load more” buttons.
  • Server-side validation.
  • Refreshing stale data.
  • Draft saves.
  • Confirmed write actions.
  • Creating reports, exports, or jobs.

Here is a simple resource component:

import { useState } from 'react';
import { useCallServerTool, useToolData } from 'sunpeak';

interface Invoice {
  id: string;
  customer: string;
  total: string;
}

interface InvoiceOutput {
  invoices: Invoice[];
  nextCursor?: string;
}

export function InvoiceList() {
  const { output } = useToolData<unknown, InvoiceOutput>();
  const callServerTool = useCallServerTool();
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string>();

  async function loadMore() {
    if (!output?.nextCursor || isLoading) return;

    setIsLoading(true);
    setError(undefined);

    try {
      const result = await callServerTool({
        name: 'load_more_invoices',
        arguments: { cursor: output.nextCursor },
      });

      if (!result || result.isError || !result.structuredContent) {
        setError('More invoices could not be loaded.');
        return;
      }

      const nextPage = result.structuredContent as InvoiceOutput;
      // Validate nextPage, then merge it into component or app state.
    } catch {
      setError('The host lost its connection to the server.');
    } finally {
      setIsLoading(false);
    }
  }

  return (
    <>
      <button type="button" disabled={!output?.nextCursor || isLoading} onClick={loadMore}>
        {isLoading ? 'Loading...' : 'Load more'}
      </button>
      {error && <p role="alert">{error}</p>}
    </>
  );
}

Three details matter.

First, handle all three failure shapes. The sunpeak hook returns undefined if the app has not connected, tool execution failures normally set result.isError, and transport or protocol failures can throw.

Second, disable the control while the request is pending. For search, filters, and pagination where several requests may overlap, keep a request sequence number and ignore older responses so stale data cannot replace newer state.

Third, keep the tool schema tight. The arguments passed from the UI must match inputSchema, and returned structuredContent should match outputSchema. Validate the result again before using it as trusted UI state when the server or host boundary is outside your control.

Use App-Only Tools for UI-Only Actions

Some tools exist only because the UI needs them. The model should call show_invoices when the user asks for invoices. The model does not need to call load_more_invoices, because the “Next page” button owns that action.

That is an app-only tool.

import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';

export const tool: AppToolConfig = {
  title: 'Load More Invoices',
  description: 'Load the next page of invoices for the current invoice view.',
  annotations: { readOnlyHint: true },
  _meta: {
    ui: {
      visibility: ['app'],
    },
  },
};

export const schema = {
  cursor: z.string().describe('The pagination cursor from the current invoice view.'),
};

type Args = z.infer<z.ZodObject<typeof schema>>;

export default async function (args: Args, _extra: ToolHandlerExtra) {
  const page = await loadInvoicePage(args.cursor);

  return {
    content: [{ type: 'text' as const, text: `Loaded ${page.invoices.length} more invoices.` }],
    structuredContent: page,
  };
}

_meta.ui.visibility: ['app'] says the rendered app can call the tool and the model cannot. The MCP Apps default is ['model', 'app'], so set app-only visibility explicitly for helper operations. This keeps the model-facing tool list smaller and reduces accidental calls.

ChatGPT still exposes window.openai.callTool(name, args) as a compatibility alias for tools/call. For new cross-host MCP Apps, prefer _meta.ui.visibility and the standard bridge shape. Add OpenAI compatibility fields only when the integration needs them, and keep the shared MCP Apps contract as the source of truth.

App-only does not mean trusted. A user can modify browser code or send protocol messages outside your intended button flow. The server must authenticate the connection, authorize the user for the requested object and action, validate all arguments, and enforce write confirmations and idempotency where needed.

Use updateModelContext for Quiet State Changes

Some UI changes should be visible to the model, but should not create a new chat message.

Examples:

  • The user selected three rows in a table.
  • The user changed a date range filter.
  • The user opened invoice INV-042.
  • The user marked a review item as approved in the UI.

That is updateModelContext.

import { useUpdateModelContext } from 'sunpeak';

export function DateRangePicker() {
  const updateModelContext = useUpdateModelContext();

  async function onRangeChange(range: { start: string; end: string }) {
    await updateModelContext({
      structuredContent: {
        selectedDateRange: range,
      },
    });
  }

  return null;
}

The model can use that state on a later turn. The host does not insert a visible chat message, and the model does not need to respond immediately.

One operational detail is easy to miss: updateModelContext replaces the View’s previous context update. It does not merge patches. Send the full context you want the model to see, or clear it intentionally with an empty content array and no stale structured fields when the user leaves that view.

The standard accepts content, structuredContent, or both. Use short text when prose is easiest for the model to read, and use structured content for IDs, filters, totals, and other fields that benefit from a stable shape. The host capability advertises which content modalities it accepts, so an image-capable host does not imply every host accepts images here.

Use this for model-readable state, not private UI data. If a value should not enter model context, keep it in component state, durable server state, or tool result _meta for the View. Treat context as data that may reach the model and logs, so send compact references and summaries instead of full records, secrets, or unneeded personal data.

Use sendMessage When the User Wants a Reply

sendMessage is for user intent that should continue the conversation.

Good examples:

  • “Explain this chart.”
  • “Draft a reply using these selected tickets.”
  • “Summarize the currently selected rows.”
  • “Ask what changed since the last report.”
import { useSendMessage, useUpdateModelContext } from 'sunpeak';

export function ExplainSelectionButton({ selectedIds }: { selectedIds: string[] }) {
  const updateModelContext = useUpdateModelContext();
  const sendMessage = useSendMessage();

  async function explainSelection() {
    await updateModelContext({
      structuredContent: {
        selectedInvoiceIds: selectedIds,
      },
    });

    await sendMessage({
      role: 'user',
      content: [{ type: 'text', text: 'Explain the selected invoices.' }],
    });
  }

  return (
    <button type="button" disabled={selectedIds.length === 0} onClick={explainSelection}>
      Explain selection
    </button>
  );
}

This two-step pattern is useful when the selected data is larger than the message should be. Put a compact selection in model context, then send a short user message that says what to do with it.

The low-level MCP Apps App.sendMessage() call returns an acknowledgement that can include isError when the host rejects the message, and it can throw on a lost connection or timeout. The current sunpeak useSendMessage() hook resolves to void, so handle thrown errors around the hook and do not read result.isError from it. Use the low-level App API when the UI must distinguish a host rejection from a successful acknowledgement.

Do not use sendMessage for every button. If the user is only expanding a row, saving a draft, or loading the next page, a chat message adds noise. Use callServerTool or updateModelContext instead.

Negotiate Capabilities Before Showing Controls

MCP Apps negotiates host capabilities during ui/initialize. The three actions in this guide use separate capability keys:

ActionCapability to checkFallback
callServerToolserverToolsHide the server action, or use already returned data
sendMessagemessageCopy a suggested prompt, or leave the result in the View
updateModelContextupdateModelContextKeep the state local and explain that it will not affect chat

At the low level, read app.getHostCapabilities() after connect(). Framework hooks may expose a simpler capability object. Check the actual capability, not a product name, because support can vary by client version, device, plan, and workspace policy.

Do not call an action before the app connection is ready. Register input, result, cancellation, teardown, and host-context handlers before connect(), then enable controls after the handshake completes. A pre-connect request can race initialization and strict hosts may reject it.

Design the fallback as part of the control. A disabled button needs a useful reason for assistive technology and sighted users, while a missing action should not trap the user in an incomplete workflow. The MCP Apps client matrix is useful during planning, but runtime capability checks remain authoritative.

Treat Every Server Action as Untrusted Input

The host bridge isolates the iframe from the parent page, but it does not replace server security. callServerTool reaches the same tool handler as another MCP client, so the handler must enforce the same rules:

  • Derive user and tenant identity from the authenticated MCP session, never from an ID supplied by the View.
  • Authorize the operation and target object on every call.
  • Validate arguments and output at runtime.
  • Mark destructive and open-world behavior accurately in tool annotations.
  • Require a clear confirmation before consequential writes.
  • Add an idempotency key to payments, submissions, deletes, and other retryable writes.
  • Keep secrets and private records out of content, structuredContent, context updates, and chat messages unless the user request requires them.

The View should never receive the MCP access token. The host owns the OAuth flow, token storage, and authenticated Host-to-server request. If a later callServerTool needs a wider scope, the server can return an insufficient-scope challenge and the host can ask the user to authorize it. A direct browser fetch uses a separate web authorization and CORS path, so MCP OAuth does not authorize it automatically.

The UI should still prevent double clicks and show pending, success, cancelled, and error states. Those controls improve the experience, but the server owns the security guarantee.

For concurrent reads, label each request with a monotonically increasing sequence and apply only the newest result. For writes, serialize conflicting operations and let the server reject stale versions. This avoids the common bug where a slow response from an old filter or draft overwrites newer user work.

Use Direct Fetch Sparingly

An MCP App iframe can make direct network calls only when the resource CSP allows the exact target origin and the server’s CORS policy permits the sandboxed origin. Even then, direct fetch from the component is usually the wrong first choice for private business data.

Prefer callServerTool when:

  • The request needs user credentials or workspace permissions.
  • The call touches your private API.
  • You need audit logs tied to the MCP session.
  • The result should also be available to the model as structuredContent.
  • You want deterministic tests without browser-level network stubs.

Direct client-side fetch can make sense for public assets, static metadata, or host-approved APIs that belong in the browser. Put API origins in connectDomains and asset origins in resourceDomains. Test redirects too, because every redirect target needs permission and dev-server behavior can hide production CSP mistakes.

ChatGPT Apps Compatibility Notes

OpenAI’s current plugin UI guide tells new ChatGPT UI integrations to start with MCP Apps. Older Apps SDK examples use names that do not appear in the portable API:

ChatGPT compatibility APIPortable MCP Apps shape
window.openai.callTool(name, args)callServerTool({ name, arguments })
window.openai.sendFollowUpMessage(...)sendMessage(...)
window.openai.setWidgetState(...) plus context updatesApp state and updateModelContext(...)
_meta["openai/outputTemplate"]_meta.ui.resourceUri

The aliases remain available for existing integrations. Use window.openai directly for ChatGPT-only features such as checkout, ChatGPT file APIs, host-owned modals, and widget-scoped persistence. Feature-detect each extension and keep a fallback.

For a new app, write the core action layer against MCP Apps, then add ChatGPT-specific branches only where the standard bridge has no equivalent. This keeps the core app portable and lets tests mock one action layer instead of hardcoding host globals through every component.

OpenAI also recommends separating data tools from render tools when a workflow needs model reasoning before UI appears. The model can call data tools, refine the result, then call one render tool with _meta.ui.resourceUri. Inside the rendered View, a refresh or reroll button can call the data tool directly with callServerTool without remounting the entire component.

A Decision Flow for UI Actions

When you wire a new control, ask these questions in order:

  1. Does this action need server logic? Use callServerTool.

  2. Should the model be allowed to start this action directly? If no, make the tool app-only with _meta.ui.visibility: ['app'].

  3. Does the host advertise the capability? If no, use the fallback and do not send the request.

  4. Does the model need to know the new UI state later? Use updateModelContext with the current selected state.

  5. Should the model respond now? Use sendMessage, usually after updating context.

  6. Is this a host-owned action such as opening a link, requesting a display mode, uploading a file, or opening a modal? Use the host capability API, and feature-detect when the capability is optional.

That sequence prevents two common bugs: tools exposed to the model just because a button needs them, and chat messages sent for UI changes that should have stayed quiet.

Testing MCP App Actions

Test action flows in layers.

Start with tool contract tests:

  • inputSchema accepts the arguments the UI sends.
  • outputSchema matches the returned structuredContent.
  • annotations match the side effect, especially read-only vs write.
  • _meta.ui.visibility matches the access model.
  • The server authenticates, authorizes, validates, and deduplicates writes.
  • Error paths cover undefined, isError, throws, cancellation, and timeouts.

Then render the resource in a host-like inspector and click the real controls. For callServerTool, simulation files can mock server tool responses:

{
  "tool": "show_invoices",
  "userMessage": "Show my recent invoices",
  "toolInput": {},
  "toolResult": {
    "structuredContent": {
      "invoices": [{ "id": "INV-001", "customer": "Acme", "total": "$120.00" }],
      "nextCursor": "cursor_2"
    }
  },
  "serverTools": {
    "load_more_invoices": [
      {
        "when": { "cursor": "cursor_2" },
        "result": {
          "content": [{ "type": "text", "text": "Loaded one more invoice." }],
          "structuredContent": {
            "invoices": [{ "id": "INV-002", "customer": "Northwind", "total": "$84.00" }]
          }
        }
      }
    ]
  }
}

That gives you a deterministic state for the first render and the follow-up UI action. You can test the button, the loading state, the merged result, and the empty or error state without a live database or a real ChatGPT or Claude session.

For updateModelContext, assert the host bridge receives the full context object you expect, including cleared states, and verify that a later update replaces the old one. For sendMessage, test the accepted path and host rejection at the low-level App boundary, plus thrown errors through framework hooks. For any action that can be clicked twice, test the disabled state, deduplication, and stale-response behavior.

Run the same component with each capability missing. The control should disappear or explain its disabled state, the rest of the View should stay usable, and no unsupported request should cross the bridge. Also test cancellation and teardown so polling, timers, and pending state stop when the host removes the View.

In sunpeak, the same local inspector runtime used for manual development can run in Playwright, so you can keep these tests in CI. That is the main payoff: MCP App actions become normal, repeatable UI tests instead of a real-host clicking routine.

Where sunpeak Fits

The universal pattern is the important part: choose the action based on user intent, route through the host bridge, keep model-visible data deliberate, and test the result.

sunpeak gives you typed React hooks for that pattern, including useCallServerTool, useSendMessage, and useUpdateModelContext. Its Inspector replicates ChatGPT and Claude host behavior locally, and simulations can provide conditional serverTools results for pagination, saves, validation, errors, and retries. The same paths run in Playwright and CI without a paid host account or model credits.

If you are building an MCP App with interactive buttons, forms, filters, or confirmation flows, start with:

npx sunpeak new

Then write the action flow as a local simulation before you connect a real host.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

What should a button inside an MCP App do?

Most MCP App buttons should do one of three things: call a server tool with callServerTool, update model-visible state with updateModelContext, or send a user-role message with sendMessage. Use callServerTool for backend work, updateModelContext when the model needs to know what changed without replying yet, and sendMessage when the user action should continue the conversation.

How does callServerTool work in an MCP App?

callServerTool asks the host to call a tool on the originating MCP server. The host proxies the standard tools/call request and returns the tool result to the View. Check the serverTools host capability first. Tool execution errors normally set result.isError, transport and protocol failures can throw, and a framework hook may return undefined before the app connects.

When should I use an app-only MCP tool?

Use an app-only tool when the rendered UI needs server logic but the model should not call that tool directly. Good examples include pagination, polling, draft saves, validation, and confirmed button actions. Mark the tool with _meta.ui.visibility set to ["app"] so it is callable from the app but hidden from the model-facing tool list.

What is the difference between sendMessage and updateModelContext?

sendMessage adds a user-role message to the host conversation and may trigger the model to respond. updateModelContext replaces the previous model-context update from the View for a future turn without adding a chat message or asking for an immediate response. Use updateModelContext for quiet state changes and sendMessage for explicit user intent.

Is window.openai.callTool still used for ChatGPT Apps?

ChatGPT exposes window.openai.callTool as an Apps SDK compatibility API for widget-initiated tool calls. For portable MCP Apps, prefer the standard MCP Apps bridge or a framework hook such as useCallServerTool. If you support older ChatGPT-only code, map window.openai.callTool behavior to the same server tools and keep the portable contract as the source of truth.

How do I make an MCP tool callable only from the app UI?

Set _meta.ui.visibility to ["app"]. MCP Apps tools are visible to both the model and app by default, so an explicit app-only setting keeps pagination, polling, draft-save, and confirmation helpers out of the model tool list. The server must still authenticate the user, authorize the action, and validate every argument.

What if an MCP host does not support an app action?

Read the negotiated host capabilities before calling an optional action. serverTools gates server tool calls, message gates sendMessage, and updateModelContext gates model-context updates. Hide or disable unsupported controls and keep a text or local-state fallback. Capability checks are more reliable than branching on a host name.

How do I test MCP App actions?

Test the tool contract first, then render the resource in a local inspector and click the real controls. For callServerTool flows, add serverTools mocks or fixtures so pagination, validation, save, and confirmation paths are deterministic. Also test missing capabilities, undefined pre-connect results, result.isError, thrown transport failures, stale responses, duplicate clicks, rejected messages, and overwritten model context.