MCP App Tool Metadata: resourceUri, visibility, and App-Only Tools
MCP App tool metadata links model-callable tools to sandboxed UI resources and controls which tools the app can call.
Most MCP App rendering bugs start before React mounts.
The model calls a tool. The host looks at the tool metadata. If the tool points at a missing ui:// resource, the iframe never loads. If the visibility is wrong, the model can see a tool it should not call, or the app cannot call a tool it needs for pagination, validation, or submit actions.
That small _meta.ui object is the routing table for your app.
TL;DR: Use _meta.ui.resourceUri to link a tool to the UI resource the host should render. Use _meta.ui.visibility to decide whether the model, the rendered app, or both can call the tool. Prefer the standard MCP Apps fields for new cross-host apps. Add ChatGPT compatibility keys only when you need to support older ChatGPT Apps SDK behavior. Test the metadata with tools/list and resources/read before debugging your frontend.
The Tool Metadata Fields That Matter
For a UI-capable MCP tool, the important fields are:
| Field | Goes on | Purpose |
|---|---|---|
_meta.ui.resourceUri | Tool descriptor | Points at the ui:// resource to render |
_meta.ui.visibility | Tool descriptor | Controls model vs app access |
_meta["openai/outputTemplate"] | Tool descriptor | ChatGPT compatibility alias for the UI resource URI |
_meta["openai/widgetAccessible"] | Tool descriptor | Older ChatGPT compatibility flag for widget-to-tool calls |
_meta["openai/visibility"] | Tool descriptor | Older ChatGPT compatibility visibility field |
The first two are the standard MCP Apps fields. The OpenAI-specific keys are useful when you need compatibility with older ChatGPT Apps SDK examples or host behavior, but they should not be the center of a new cross-host implementation.
The OpenAI Apps SDK reference now says to prefer _meta.ui.resourceUri for linking a tool to a UI template, with _meta["openai/outputTemplate"] as an optional compatibility alias. The MCP Apps reference defines the portable shape: resourceUri?: string and visibility?: ("model" | "app")[].
resourceUri Links a Tool to a View
_meta.ui.resourceUri tells the host which resource to render when a tool runs.
import { registerAppTool } from '@modelcontextprotocol/ext-apps/server';
import { z } from 'zod';
registerAppTool(
server,
'get-weather',
{
title: 'Get Weather',
description: 'Get current weather for a location',
inputSchema: {
location: z.string(),
},
_meta: {
ui: {
resourceUri: 'ui://weather/view.html',
},
},
},
async ({ location }) => {
const forecast = await getForecast(location);
return {
content: [{ type: 'text', text: `Displayed weather for ${location}.` }],
structuredContent: forecast,
};
},
);
That URI must match a resource your MCP server registers. The host flow is:
- The host lists tools and sees
_meta.ui.resourceUri. - The host reads the matching
ui://resource. - The host renders that HTML in a sandboxed iframe.
- The host passes tool input and tool output into the resource.
If the resource URI is wrong, the backend tool may still return valid data, but the app UI will not render. This is why resourceUri belongs in your first test layer, before E2E tests.
The sunpeak Tool File Version
In sunpeak projects, you usually do not write resourceUri by hand. You link a tool file to a resource directory with the resource field:
// src/tools/get-weather.ts
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';
export const tool: AppToolConfig = {
resource: 'weather',
title: 'Get Weather',
description: 'Get current weather for a location',
annotations: { readOnlyHint: true },
_meta: {
ui: {
visibility: ['model', 'app'],
},
},
};
export const schema = {
location: z.string().describe('City and region, such as Chicago, IL'),
};
type Args = z.infer<z.ZodObject<typeof schema>>;
export default async function (args: Args, _extra: ToolHandlerExtra) {
const forecast = await getForecast(args.location);
return {
content: [{ type: 'text' as const, text: `Displayed weather for ${args.location}.` }],
structuredContent: forecast,
};
}
resource: 'weather' maps to src/resources/weather/. sunpeak generates and registers the corresponding MCP App resource URI during build and dev. You still control visibility through _meta.ui.visibility.
That split is useful. Application code stays simple, while generated server output still uses standard MCP Apps metadata.
visibility Controls Who Can Call the Tool
_meta.ui.visibility accepts an array with "model", "app", or both.
| Value | Meaning | Good for |
|---|---|---|
["model", "app"] | The model can call it and the rendered app can call it | Search, display, refresh, and read actions |
["model"] | Only the model can call it | Conversation-driven tools the UI should never trigger |
["app"] | Only the rendered app can call it | Pagination, polling, validation, draft saves, confirmed button actions |
If you omit visibility, the default is usually ["model", "app"]. That is a fine default for simple tools, but it becomes too broad as your app grows.
App-only tools are the pattern many developers miss.
Imagine an invoice viewer. The model should call show-invoices when the user asks for invoices. The UI should call load-more-invoices when the user clicks “Next page.” The model does not need to see that pagination tool, and hiding it reduces tool-list noise.
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'],
},
},
};
Then the resource calls the tool from the UI:
import { useCallServerTool, useToolData } from 'sunpeak';
interface InvoicePage {
invoices: Array<{ id: string; customer: string; total: string }>;
nextCursor?: string;
}
export function InvoiceResource() {
const { output } = useToolData<unknown, InvoicePage>();
const callServerTool = useCallServerTool();
if (!output) return null;
async function loadMore() {
if (!output.nextCursor) return;
await callServerTool({
name: 'load-more-invoices',
arguments: {
cursor: output.nextCursor,
},
});
}
return (
<button onClick={loadMore} disabled={!output.nextCursor}>
Next page
</button>
);
}
That keeps the user workflow interactive without giving the model a low-level paging tool it does not need.
A Practical Visibility Model
Use this rule when designing tools:
If the tool answers a user request, make it model-visible. If the tool handles a UI event after the app is already open, make it app-visible. If both paths are valid, expose it to both.
Common patterns:
| Tool | Visibility | Why |
|---|---|---|
show-dashboard | ["model", "app"] | The model opens it, and the app may refresh it |
load-dashboard-page | ["app"] | Pagination is a UI concern |
validate-form-field | ["app"] | The model should not call field validation directly |
submit-review-decision | ["app"] | The UI calls it after a user clicks confirm or cancel |
explain-report | ["model"] | The model uses it in conversation, not the iframe |
For write actions, visibility is not enough by itself. You still need server-side auth, input validation, idempotency where possible, and explicit user confirmation for risky work. Tool visibility reduces accidental exposure. It does not replace authorization.
ChatGPT Compatibility Keys
Older ChatGPT Apps SDK examples often use keys like:
_meta: {
'openai/outputTemplate': 'ui://weather/view.html',
'openai/widgetAccessible': true,
}
For new MCP Apps, prefer:
_meta: {
ui: {
resourceUri: 'ui://weather/view.html',
visibility: ['model', 'app'],
},
}
If you need compatibility with older ChatGPT behavior, you can include both fields with the same URI:
_meta: {
ui: {
resourceUri: 'ui://weather/view.html',
visibility: ['model', 'app'],
},
'openai/outputTemplate': 'ui://weather/view.html',
'openai/widgetAccessible': true,
}
Keep one source of truth. If the standard resourceUri and the OpenAI alias point at different resources, debugging gets messy fast because each host may pick a different path.
The same idea applies to visibility. Prefer _meta.ui.visibility. Only add _meta["openai/visibility"] when you are targeting a host path that still needs it.
What Does Not Belong on Tool Metadata
Do not put iframe security settings on the tool.
These belong on resource metadata:
- CSP domains, such as
connectDomains,resourceDomains, andframeDomains - Browser permissions, such as camera, microphone, geolocation, and clipboard write
- Stable sandbox domains
- Border and framing hints
The tool answers “what can be called, and which UI should appear?” The resource answers “how should this iframe be rendered and sandboxed?”
That separation matters because multiple tools can share one resource. If show-cart, apply-discount, and refresh-cart all render ui://shop/cart.html, the CSP should live with the cart resource, not be duplicated across every tool.
Test Tool Metadata Before the Browser
A conformance test catches metadata bugs faster than a rendered UI test.
import { test, expect } from 'sunpeak/test';
test('ui tools point at readable app resources', async ({ mcp }) => {
const tools = await mcp.listTools();
const appTools = tools.filter((tool) => tool._meta?.ui?.resourceUri);
expect(appTools.length).toBeGreaterThan(0);
for (const tool of appTools) {
const ui = tool._meta?.ui;
expect(ui?.resourceUri).toMatch(/^ui:\/\//);
expect(ui?.visibility ?? ['model', 'app']).toEqual(expect.arrayContaining(['model']));
const resource = await mcp.readResource(ui.resourceUri);
expect(resource.contents).toHaveLength(1);
expect(resource.contents[0].mimeType).toBe('text/html;profile=mcp-app');
}
});
For app-only tools, test the other direction:
test('app-only tools are marked app visible', async ({ mcp }) => {
const tools = await mcp.listTools({ includeAppOnly: true });
const loadMore = tools.find((tool) => tool.name === 'load-more-invoices');
expect(loadMore?._meta?.ui?.visibility).toEqual(['app']);
});
Your exact test harness may expose model-visible and app-visible tool lists differently. The intent is what matters: assert the metadata contract directly, then run browser tests after that passes.
Debugging Checklist
When an MCP App or ChatGPT App tool runs but no UI appears, check these in order:
- The tool appears in
tools/list. - The tool has
_meta.ui.resourceUri. - The URI starts with
ui://. - The URI matches a registered resource.
resources/readreturns HTML for that URI.- The resource MIME type is
text/html;profile=mcp-app. - CSP and permissions are on the resource, not the tool.
- App-only tools include
"app"in_meta.ui.visibility. - Model-only tools include
"model"and are not expected to be called from the iframe. - ChatGPT compatibility aliases, if present, point at the same resource as
_meta.ui.resourceUri.
If those checks pass, move to frontend debugging: console errors, bridge initialization, useToolData() shape, display mode layout, and CSP-blocked network calls.
Where sunpeak Helps
You can write all of this by hand with the MCP Apps SDK. For production apps, the hard part is keeping it correct as tools and resources change.
sunpeak keeps the common path short:
resource: 'weather'links a tool file tosrc/resources/weather/._meta.ui.visibilitystays available when you need explicit model vs app access.- The inspector lets you run tools and inspect tool metadata locally.
- The
mcpfixture lets you testtools/list,resources/read, and tool results in CI.
That means you can treat tool metadata as a contract, not a thing you inspect manually after ChatGPT or Claude fails to render a card.
Start with the MCP App framework if you are building a portable app, or use the testing framework if you already have an MCP server and want metadata checks in CI.
Get Started
npx sunpeak new
Further Reading
- MCP App conformance testing - verify tool metadata and resource links in CI
- Testing MCP App data flow - content, structuredContent, _meta, and host bridge state
- Testing multi-tool MCP Apps - coordinate UI tools and backend tools
- MCP App TypeScript types - type tool input and structuredContent
- MCP App CSP domains - resource metadata belongs on resources, not tools
- MCP App framework
- ChatGPT App framework
- Claude Connector framework
- sunpeak docs - MCP App tool _meta
- sunpeak docs - Tool File reference
- MCP Apps overview - Model Context Protocol
- McpUiToolMeta reference - MCP Apps
- Apps SDK reference - OpenAI
Frequently Asked Questions
What is _meta.ui.resourceUri in an MCP App?
_meta.ui.resourceUri is the tool metadata field that points a UI-capable MCP tool at the HTML resource the host should render. The value uses a ui:// URI and must match a registered MCP App resource. When the tool runs, the host reads that resource and renders it in a sandboxed iframe.
What is _meta.ui.visibility in MCP Apps?
_meta.ui.visibility controls who can discover and call a tool. Use "model" when the AI model should see and call the tool. Use "app" when the rendered UI should be able to call the tool through the MCP App bridge. The default is usually ["model", "app"], which makes the tool available to both.
When should I use an app-only MCP App tool?
Use an app-only tool for UI-driven server calls that the model should not initiate directly, such as pagination, polling, form field validation, draft saves, or a confirmed button action inside the app. Set _meta.ui.visibility to ["app"] so the resource can call it while the model tool list stays smaller and safer.
Is _meta["openai/outputTemplate"] still needed for ChatGPT Apps?
For new cross-host MCP Apps, prefer the standard _meta.ui.resourceUri field. OpenAI documents _meta["openai/outputTemplate"] as an OpenAI-specific optional compatibility alias for ChatGPT. If you are supporting older ChatGPT Apps SDK surfaces, you may include the alias with the same ui:// value, but the standard field should be your source of truth.
What is the difference between _meta.ui.visibility and _meta["openai/widgetAccessible"]?
_meta.ui.visibility is the standard MCP Apps field. It can expose a tool to the model, the app, or both. _meta["openai/widgetAccessible"] is an OpenAI-specific compatibility field used by older Apps SDK integrations to allow widget-to-tool calls. Prefer _meta.ui.visibility for portable MCP Apps.
Do CSP and permissions go on the tool or the resource?
CSP, sandbox permissions, stable domains, and border hints belong on the MCP App resource metadata, not the tool metadata. Tool metadata links a tool to a UI resource and controls tool visibility. Resource metadata controls how the sandboxed iframe may load assets, call APIs, request browser permissions, and appear inside the host.
How do I test MCP App tool metadata?
Write a conformance test that calls tools/list, finds every UI-capable tool, checks _meta.ui.resourceUri starts with ui://, reads that resource, verifies the MCP App HTML MIME type, and asserts _meta.ui.visibility matches your intended access model. Also test that app-only tools do not appear in model-visible tool lists when your host or harness exposes that distinction.