MCP App Navigation: Internal Routes, External Links, Modals, and Close Actions

MCP App navigation has to route internal views inside the iframe while asking the host to open trusted external links.
MCP App tutorials usually stop once the host renders the first resource. That leaves a practical question unanswered: what happens when the user clicks around?
Navigation is different inside an MCP App because your UI is not the top-level web page. It is a sandboxed iframe inside ChatGPT, Claude, or another MCP host. Internal routes can stay in the iframe. External URLs should go through the host bridge. Write actions should go through MCP tools. ChatGPT also has host-specific APIs for opening external flows, modals, close actions, and fullscreen destinations.
TL;DR: Use normal client-side routing for screens that stay inside the MCP App resource. Use ui/open-link, or a framework wrapper around it, for external links. Use ChatGPT window.openai.openExternal, requestModal, requestClose, and setOpenInAppUrl only behind feature detection. Declare CSP and redirect allowlists intentionally. Test routes, link requests, denied host actions, and fallback UI locally before relying on a live host.
Why Navigation Is the Gap
The current developer search map has good answers for tools, resources, tool results, app state, display modes, forms, CSP, and cross-host testing. Navigation sits between those topics.
The searches look like this:
MCP App open external linkChatGPT App openExternal redirectUrlMCP App internal routing React RouterChatGPT App requestCloseChatGPT App requestModalMCP App deep linkopenai/widgetCSP redirect_domainsMCP App navigation history
These are not beginner questions. They usually show up after the developer has a resource rendering and wants it to behave like a real app: detail pages, checkout flows, docs links, account settings, maps, fullscreen editors, or a “done” action that closes the surface.
The trap is treating the iframe like a normal web page. Some browser APIs still work, but the host owns the outer shell, security prompt, conversation state, and review policy. Good MCP App navigation separates four jobs.
| Job | Preferred path | Why |
|---|---|---|
| Move between views inside the same resource | Client-side router or local state | The user stays inside the iframe |
| Open a trusted external URL | ui/open-link or App.openLink | The host can apply link policy |
| Run a backend action | MCP tool call | The model and host keep a typed action record |
| Use ChatGPT-only UI features | window.openai extension after feature detection | Other MCP hosts may not support it |
Keep Internal Routes Internal
Internal routes are screens that belong to the same resource. A list item opens a detail panel. A dashboard switches to a chart. A wizard moves from “review” to “confirm.” None of those needs a top-level browser navigation.
Use React Router, TanStack Router, hash routes, or local state. The important part is that the route is app state, not a server write and not an external handoff.
import { BrowserRouter, Link, Route, Routes, useParams } from 'react-router-dom';
import { SafeArea, useToolData } from 'sunpeak';
interface InvoiceSummary {
id: string;
number: string;
customer: string;
total: string;
}
interface InvoiceOutput {
invoices: InvoiceSummary[];
}
function InvoiceList() {
const { output } = useToolData<unknown, InvoiceOutput>();
return (
<div className="space-y-2">
{output?.invoices.map((invoice) => (
<Link
className="block rounded-md border p-3 hover:bg-slate-50"
key={invoice.id}
to={`/invoice/${invoice.id}`}
>
<span className="font-medium">{invoice.number}</span>
<span className="ml-2 text-sm opacity-70">{invoice.customer}</span>
</Link>
))}
</div>
);
}
function InvoiceDetail() {
const { invoiceId } = useParams();
const { output } = useToolData<unknown, InvoiceOutput>();
const invoice = output?.invoices.find((item) => item.id === invoiceId);
if (!invoice) return <p>Invoice not found.</p>;
return (
<article>
<Link className="text-sm underline" to="/">
Back to invoices
</Link>
<h1 className="mt-4 text-lg font-semibold">{invoice.number}</h1>
<p>{invoice.customer}</p>
<p className="mt-2">{invoice.total}</p>
</article>
);
}
export function InvoiceResource() {
return (
<SafeArea className="p-4 font-sans">
<BrowserRouter>
<Routes>
<Route path="/" element={<InvoiceList />} />
<Route path="/invoice/:invoiceId" element={<InvoiceDetail />} />
</Routes>
</BrowserRouter>
</SafeArea>
);
}
That pattern is fine when the route only changes what the resource displays. If the route matters to the model later, also update model-visible context with a short summary, such as “The user is viewing invoice INV-1042.” Do not stream every route transition into model context. Save it for state the model needs on follow-up turns.
Do Not Use Links for Writes
A link can move a user to another screen. A tool should change data.
For example, this is a routing action:
- Open ticket
TK-1234 - Switch to the “activity” tab
- Expand a chart fullscreen
This is a tool action:
- Assign ticket
TK-1234to a teammate - Submit an invoice update
- Create a reservation
- Cancel a subscription
The difference matters because the host and model understand MCP tool calls. A write can have an input schema, tool annotations, permission prompts, auth checks, server validation, and a tool result. A link click has none of that unless you build it back by hand.
For UI buttons that perform app-only actions, use an app-visible tool:
import { z } from 'zod';
import type { AppToolConfig } from 'sunpeak/mcp';
export const tool: AppToolConfig = {
title: 'Save Invoice Note',
description: 'Save a note the user entered in the invoice app UI.',
inputSchema: {
invoiceId: z.string(),
body: z.string().min(1).max(2000),
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
openWorldHint: true,
},
_meta: {
ui: {
visibility: ['app'],
},
},
};
Keep the model-visible tool clean. Keep UI helper tools app-only when they are just button handlers. The MCP Apps overview describes app-only tools as the right place for UI interactions such as refresh buttons, pagination, and form submissions.
Open External Links Through the Host
External links leave the iframe. The portable MCP Apps request is ui/open-link, exposed in the low-level SDK as App.openLink.
The request is small:
await app.openLink({ url: 'https://docs.example.com/invoices/INV-1042' });
But the behavior is host-owned. The host may open the link directly, show a confirmation prompt, rewrite it through a safe-link service, or deny it. Your UI should handle all of those paths.
import { useState } from 'react';
interface LinkBridge {
openLink(params: { url: string }): Promise<{ isError?: boolean } | undefined>;
}
export function DocsLinkButton({ app, href }: { app: LinkBridge; href: string }) {
const [error, setError] = useState<string | null>(null);
async function openDocs() {
setError(null);
const result = await app.openLink({ url: href });
if (!result || result.isError) {
setError('Could not open the link from this host.');
}
}
return (
<div>
<button type="button" onClick={openDocs}>
Open docs
</button>
{error && (
<p className="mt-2 text-sm" role="status">
{error} Copy this URL instead: {href}
</p>
)}
</div>
);
}
The fallback matters. Some hosts may not support link opening yet. Some enterprise workspaces may restrict external navigation. Some URLs may fail allowlist checks. The user still needs a path forward, so expose the URL, an inline summary, or a server-side alternative.
Do not rely on window.open, target="_blank", or parent-frame access. MCP App iframes are sandboxed on purpose. The host bridge is the contract.
ChatGPT openExternal and redirectUrl
ChatGPT also exposes window.openai.openExternal({ href, redirectUrl }). Use it when you need ChatGPT-specific behavior. For example, a checkout flow might need ChatGPT to append a return URL so the external site can bring the user back to the same conversation.
async function openCheckout(app: LinkBridge, checkoutUrl: string) {
if (window.openai?.openExternal) {
await window.openai.openExternal({
href: checkoutUrl,
redirectUrl: true,
});
return;
}
await app.openLink({ url: checkoutUrl });
}
Feature detection is not optional here. window.openai exists in ChatGPT, not in every MCP App host. Claude Connectors and other compatible hosts should still render the component and offer the portable link path.
ChatGPT uses standard _meta.ui.csp for the app resource CSP, such as connectDomains, resourceDomains, and frameDomains. One ChatGPT-specific field still matters for trusted external redirects: redirect_domains inside _meta["openai/widgetCSP"].
export const resourceMeta = {
_meta: {
ui: {
csp: {
connectDomains: ['https://api.example.com'],
resourceDomains: ['https://cdn.example.com'],
},
domain: 'https://invoice-app.example.com',
},
'openai/widgetCSP': {
redirect_domains: ['https://billing.example.com'],
},
},
};
Keep this list tight. A redirect allowlist is not a generic link policy. Add the exact origins that need trusted ChatGPT handoff behavior. For ordinary docs links, help links, and marketing links, the portable open-link path is usually enough.
Modals and Close Actions Are Host Features
ChatGPT can open host-controlled modals with window.openai.requestModal. The modal can use the current template or another registered template URI from the same app.
async function showTerms() {
if (window.openai?.requestModal) {
await window.openai.requestModal({
template: 'ui://invoice-app/terms.html',
});
return;
}
setInlinePanel('terms');
}
The fallback should be a normal inline panel. A modal is a presentation option, not the only way to complete the task.
The same rule applies to close actions. ChatGPT widgets can request close from the UI:
async function finishFlow() {
await saveDraft();
if (window.openai?.requestClose) {
await window.openai.requestClose();
return;
}
setStatus('complete');
}
A server result can also ask ChatGPT to close a widget with openai/closeWidget metadata. That is useful after a compact confirmation flow, but it should not be the only success state. Other hosts may ignore the field, and even ChatGPT users may need visible confirmation before the app disappears.
setOpenInAppUrl and Fullscreen Destinations
ChatGPT fullscreen surfaces can expose an “Open in app” destination. By default, that target uses the current iframe path. window.openai.setOpenInAppUrl({ href }) lets you point it at a better URL, such as the same record in your hosted product.
import { useEffect } from 'react';
useEffect(() => {
if (!window.openai?.setOpenInAppUrl || !invoice) return;
window.openai.setOpenInAppUrl({
href: `https://app.example.com/invoices/${invoice.id}`,
});
}, [invoice]);
Use this for product-owned pages that are useful outside ChatGPT. Do not use it to smuggle internal iframe routes into a top-level browser context if those routes only work with host-provided tool data.
If the hosted page needs the same authorization state as your MCP server, design that flow explicitly. The ChatGPT App iframe, your MCP server, and your product web app may have different session models.
Routing, App State, and Model Context
Internal routing answers “what should the user see right now?” App state answers “what should survive app updates?” Model context answers “what should the model know after this UI interaction?”
Do not collapse those into one global object.
| State | Example | Store it where |
|---|---|---|
| Current tab | activity | Router, component state, or app state |
| Selected record | invoiceId: inv_123 | App state if follow-up turns need it |
| Model-visible summary | User selected invoice INV-1042 | ui/update-model-context |
| Draft text | Unsaved note body | Local component state |
| Server write | Saved note | MCP tool and backend database |
For a detail route, you might update app state and model context only when the selection becomes meaningful:
import { useEffect } from 'react';
import { useAppState, useUpdateModelContext } from 'sunpeak';
function useSelectedInvoice(invoice: InvoiceSummary | null) {
const [, setAppState] = useAppState<{ selectedInvoiceId: string } | null>(null);
const updateModelContext = useUpdateModelContext();
useEffect(() => {
if (!invoice) return;
setAppState({ selectedInvoiceId: invoice.id });
updateModelContext({
content: [
{
type: 'text',
text: `The user is viewing invoice ${invoice.number} for ${invoice.customer}.`,
},
],
});
}, [invoice, setAppState, updateModelContext]);
}
Keep this short. The model does not need a route dump, query string, scroll position, or every clicked tab. It needs stable facts that can improve follow-up answers.
Security Rules for Navigation
MCP App navigation bugs are often security bugs with a UI face.
Use these rules:
- Never pass arbitrary model-generated URLs directly into
openLink. - Parse and validate URLs on the server or against a local allowlist before opening them.
- Prefer fixed product origins and typed IDs over full URLs in tool results.
- Keep
redirect_domainsas small as possible for ChatGPT Apps. - Do not put secrets, tokens, or one-time auth codes in iframe routes.
- Do not use iframe navigation as a substitute for OAuth.
- Treat denied host actions as expected states.
Here is a simple URL guard:
const allowedOrigins = new Set(['https://docs.example.com', 'https://app.example.com']);
export function getTrustedUrl(rawUrl: string) {
const url = new URL(rawUrl);
if (!allowedOrigins.has(url.origin)) {
throw new Error(`External origin is not allowed: ${url.origin}`);
}
return url.toString();
}
Do this before the URL reaches the resource. If the model can invent href values, your server should reduce those values to safe targets: a route name, a resource ID, or a URL produced by your backend.
Test the Navigation Matrix
Navigation needs tests because host behavior differs in ways a standalone browser page will not catch.
At minimum, test these cases:
- Initial render at the default route
- Direct entry to a detail route
- Back navigation from detail to list
- Route state after a tool result update
- App state restoration after remount
- Mobile width with long labels and nested routes
- External link request success
- External link request denied
- Host does not support external links
- ChatGPT
openExternalpath, if used - Inline fallback for ChatGPT-only modals and close actions
In sunpeak, run the app in the local inspector and write Playwright tests against the rendered app frame:
import { expect, test } from 'sunpeak/test';
test('opens invoice detail inside the app iframe', async ({ inspector }) => {
const result = await inspector.renderTool('show-invoices', {
output: {
invoices: [{ id: 'inv_123', number: 'INV-1042', customer: 'Acme', total: '$120.00' }],
},
});
const app = result.app();
await app.getByRole('link', { name: /INV-1042/ }).click();
await expect(app.getByRole('heading', { name: 'INV-1042' })).toBeVisible();
await app.getByRole('link', { name: /Back to invoices/ }).click();
await expect(app.getByRole('link', { name: /INV-1042/ })).toBeVisible();
});
Use a focused unit test for the exact bridge request:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
test('requests external links through the host bridge', async () => {
const openLink = vi.fn().mockResolvedValue({});
render(
<DocsLinkButton app={{ openLink }} href="https://docs.example.com/invoices/INV-1042" />,
);
await userEvent.click(screen.getByRole('button', { name: 'Open docs' }));
expect(openLink).toHaveBeenCalledWith({
url: 'https://docs.example.com/invoices/INV-1042',
});
});
The exact helper names depend on your app wiring, but the assertion shape matters: click the UI, assert the bridge request, then cover the failure path. Do not only assert that a button exists.
For ChatGPT-specific APIs, mock window.openai in a unit test and keep a small live smoke test for review:
test('uses ChatGPT openExternal when available', async () => {
const openExternal = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('window', {
...window,
openai: { openExternal },
});
await openCheckout(
{ openLink: vi.fn() },
'https://billing.example.com/checkout/session_123',
);
expect(openExternal).toHaveBeenCalledWith({
href: 'https://billing.example.com/checkout/session_123',
redirectUrl: true,
});
});
Then run the same component in a non-ChatGPT host mode with window.openai missing. That is where many cross-host bugs show up.
A Practical Decision Tree
When a user clicks something in an MCP App, ask one question first: does this stay inside the resource?
If yes, use internal routing or local state. If the new view matters to future model turns, update app state or model context with a short summary.
If no, ask whether the click opens an external page or changes backend data. External pages go through ui/open-link or window.openai.openExternal when ChatGPT-specific redirect behavior is needed. Backend changes go through tools.
If the click changes the host surface, such as modal, fullscreen, close, or “open in app,” treat it as an optional host feature. Feature-detect it and provide inline fallback UI.
That split keeps the app portable, testable, and easier to review.
Where sunpeak Fits
The fastest way to debug MCP App navigation is to get out of the live host loop. With sunpeak, you can run a local inspector, render the same tool output in ChatGPT and Claude-style host replicas, switch display modes, inspect app state, and assert bridge requests in tests.
For a new app:
npx sunpeak new
For an existing MCP server:
npx sunpeak inspect --server http://localhost:8000/mcp
Navigation is exactly the kind of behavior worth testing locally because the failure cases are slow to reproduce by hand: denied external links, missing host capabilities, iframe route restoration, mobile clipping, and ChatGPT-only APIs running in a non-ChatGPT host.
Start with the MCP App framework, inspect your current server with the sunpeak inspector, or add coverage with the MCP testing framework. The goal is simple: when a user clicks, the app should either route, ask the host, or call a tool, and your tests should prove which path happened.
Get Started
npx sunpeak newFurther Reading
- MCP App resource metadata - CSP, permissions, and ChatGPT widget fields
- MCP App actions - callServerTool, sendMessage, and updateModelContext
- MCP App capability detection - host features, fallbacks, and tests
- MCP App CSP domains - connectDomains, resourceDomains, and frameDomains
- MCP App lifecycle - connect, tool input, results, and teardown
- MCP App framework
- ChatGPT App framework
- Claude Connector framework
- sunpeak docs - MCP Apps openLink
- MCP Apps open-link request reference
- OpenAI Apps SDK - build your ChatGPT UI
- OpenAI Apps SDK reference
Frequently Asked Questions
How should navigation work inside an MCP App?
Use normal client-side routing for internal screens that stay inside the resource iframe. Use the MCP Apps host bridge, such as App.openLink or ui/open-link, for external URLs because the iframe is sandboxed and the host needs to apply its own link policy. Keep write actions behind tools, not links.
Can an MCP App open an external link?
Yes. The portable MCP Apps method is ui/open-link, exposed by the low-level App.openLink API. The app asks the host to open a URL in the user browser, and the host may approve, prompt, deny, or rewrite the request according to its security policy. Do not depend on target=_blank or window.open inside the iframe.
What is the difference between App.openLink and window.openai.openExternal?
App.openLink is the portable MCP Apps bridge request for opening a URL through the host. window.openai.openExternal is a ChatGPT-specific compatibility API with ChatGPT-only options such as redirectUrl. For cross-host MCP Apps, use the portable bridge as the default path and keep window.openai calls behind feature detection when you need ChatGPT-specific behavior.
What is redirect_domains in a ChatGPT App?
redirect_domains is a ChatGPT-specific allowlist inside openai/widgetCSP. It is used for trusted window.openai.openExternal destinations, especially flows such as checkout or OAuth-adjacent handoffs that need a return link back to the conversation. Standard _meta.ui.csp is still the preferred CSP surface for connect, resource, and frame domains.
Should an MCP App use React Router?
React Router is fine for internal resource navigation as long as the routed views are part of the same app resource and do not bypass the host bridge. Use it for detail panels, tabs, nested screens, and browser history inside the iframe. Test routes in the host iframe, not only in a standalone browser page.
Can a ChatGPT App close its widget?
ChatGPT-specific widgets can request close from the UI with window.openai.requestClose. A server response can also ask ChatGPT to close a widget by setting openai/closeWidget in response metadata. Treat this as host-specific behavior and provide a normal inline completion state for hosts that do not support close actions.
How do I test MCP App navigation and external links?
Test internal routes with inspector E2E tests, including direct entry, back navigation, state restoration, and mobile widths. Mock host bridge requests for external links, assert the exact URL, and test denial or unavailable-capability paths. For ChatGPT-only openExternal behavior, add a focused compatibility test and a small live smoke test before submission.