All posts

MCP App Actions: callServerTool, sendMessage, and updateModelContext

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.

The first MCP App search usually asks how to get a UI on screen. The next search is more specific: what should happen when someone clicks a button inside that UI?

That question is where many ChatGPT App and Claude Connector builds get messy. A normal React app can fetch('/api/save') from an event handler. An MCP App runs inside a host-owned iframe, with tool access, model context, and chat messages routed through the host bridge. The event handler still starts in React, but the action has to choose the right MCP App request.

TL;DR: Use callServerTool when the UI needs backend work. Use updateModelContext when the model should know about the latest UI state without replying yet. Use sendMessage when the user action should continue the conversation. Use app-only tools for UI actions the model should not call directly. For ChatGPT Apps, treat window.openai.callTool and _meta["openai/widgetAccessible"] as compatibility APIs unless you are writing ChatGPT-only code.

Why This Is the Search Gap

The current MCP App content map has plenty of first-app posts, metadata references, and testing guides. Those are useful, but they leave a practical intent uncovered:

  • window.openai.callTool vs callServerTool
  • MCP App button click backend call
  • ChatGPT App widget can call tool
  • MCP App send message from component
  • app-only tool vs model tool
  • _meta["openai/widgetAccessible"] vs _meta.ui.visibility
  • update model context from MCP App UI

Those searches come from builders who already have a rendered resource. Their next problem is event routing. They need to know which action to use, what data becomes visible to the model, and how to test the flow without clicking through a real host for every state.

The Four Action Types

Most MCP App controls fall into four buckets.

UI intentUseWhat happens
Fetch, save, validate, paginate, refreshcallServerToolThe host calls a tool on your MCP server and returns the result to the app
Tell the model what changedupdateModelContextThe model can read the new app state on a future turn
Continue the conversationsendMessageThe host inserts a user-role message into chat
Open links, request display changes, upload filesHost capability APIsThe app asks the host to perform a host-owned action

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 { 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();

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

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

    if (result.isError) {
      // Render an inline error state. Tool-level failures usually do not throw.
      return;
    }

    const nextPage = result.structuredContent as InvoiceOutput;
    // Merge nextPage into component state or app state.
  }

  return (
    <button type="button" disabled={!output?.nextCursor} onClick={loadMore}>
      Load more
    </button>
  );
}

Two details matter.

First, check result.isError. Tool-level errors are part of the tool result. Network, host bridge, and protocol failures can still throw, so production code should handle both.

Second, keep the tool schema tight. The arguments passed from the UI still need to match the tool inputSchema, and the result should match outputSchema when the tool returns structuredContent.

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. It also keeps the model-facing tool list smaller and reduces accidental calls.

The older ChatGPT Apps SDK pattern is _meta["openai/widgetAccessible"]. OpenAI still documents it as a compatibility field, and ChatGPT still exposes window.openai.callTool(name, args). For new cross-host MCP Apps, prefer _meta.ui.visibility and the standard bridge shape. If you need ChatGPT compatibility keys, add them as aliases, not as the only contract your app understands.

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 need to insert a visible chat message, and the model does not need to respond immediately.

One operational detail is easy to miss: updateModelContext replaces the previously set context for that app state surface. Send the full context you want the model to see, or clear it intentionally when the user leaves that view.

Use this for model-readable state, not private UI data. If a value should not enter model context, keep it in component state, app state that stays private, or tool result _meta where your host supports UI-only result metadata.

For teams routing product rules, policies, or workflow state into many agents, this is one form of AI agent context injection: send the model the current state it needs, then keep private UI state out of that channel.

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

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

    if (result.isError) {
      // Show a retry option or leave the user in the current UI state.
    }
  }

  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 the data in model context, then send a short user message that says what to do with it.

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.

Use Direct Fetch Sparingly

An MCP App iframe can make direct network calls only when the resource CSP allows the target 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 API calls that belong in the browser. Keep those domains explicit in resource CSP and test the production iframe, because dev-server behavior can hide CSP mistakes.

ChatGPT Apps Compatibility Notes

If you are reading older ChatGPT Apps examples, you will see names that do not appear in the portable MCP Apps 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/widgetAccessible"]_meta.ui.visibility with "app"
_meta["openai/outputTemplate"]_meta.ui.resourceUri

Those ChatGPT APIs are still useful when you write ChatGPT-only code or maintain an older Apps SDK integration. The safer default for a new MCP App is to write against the portable contract, then add ChatGPT-specific branches only where ChatGPT has a feature the standard bridge does not cover.

That keeps your core app logic portable across MCP App hosts, and it makes tests easier because you can mock one action layer instead of hardcoding host globals through every 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 model need to know the new UI state later? Use updateModelContext with the current selected state.

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

  5. 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.
  • Error paths return isError or throw in a way the UI handles.

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. For sendMessage, test the accepted path and the host-rejected path. For any action that can be clicked twice, test the disabled or in-flight state.

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. It also gives you a local MCP App inspector where you can run the same UI action flows against ChatGPT-like and Claude-like host runtimes without paid host accounts or AI 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 lets the rendered MCP App resource ask the host to call a tool on the originating MCP server. The host proxies the request back to the server and returns the tool result to the resource. Tool execution errors are usually returned as result.isError, while transport or protocol failures throw.

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 updates state the model can read later without sending 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.

Do I still need _meta["openai/widgetAccessible"]?

For new cross-host MCP Apps, prefer _meta.ui.visibility. OpenAI documents _meta["openai/widgetAccessible"] as an OpenAI-specific compatibility field for existing Apps SDK apps, while _meta.ui.visibility is the portable field that controls model and app access. Include the OpenAI key only when you need that ChatGPT compatibility path.

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 result.isError, thrown transport failures, disabled double-click states, and model context updates.