MCP App Tool Metadata: resourceUri, visibility, and App-Only Tools (August 2026)

MCP App tool metadata links model-callable tools to sandboxed UI resources and controls which tools the app can call.
MCP App rendering starts with a tool descriptor, before the host creates an iframe or your React component mounts. One wrong URI can stop the View from loading, while one broad visibility setting can expose a UI helper to the model.
TL;DR: Put resourceUri and visibility under _meta.ui. Use resourceUri only when a tool should launch or associate a View. Use visibility: ['app'] for server tools that only the open View should call. Treat visibility as a host routing rule, keep authorization on the server, and test the raw descriptor plus the host-filtered behavior.
The stable tool metadata contract
The stable MCP Apps specification defines this shape:
interface McpUiToolMeta {
resourceUri?: string;
visibility?: Array<'model' | 'app'>;
}
The two fields answer separate questions:
| Field | Question it answers | Default |
|---|---|---|
_meta.ui.resourceUri | Which ui:// resource should the host render? | No View |
_meta.ui.visibility | May the model, the View, or both call this tool? | ['model', 'app'] |
The current MCP Apps protocol version is 2026-01-26. Core MCP 2026-07-28 changed the Host-to-server lifecycle to a stateless request model, but it did not change this tool-to-View metadata shape. Keep the two protocol layers separate when you debug version problems.
resourceUri links a tool to a UI resource
A UI launcher points at a resource registered on the same MCP server:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import {
registerAppResource,
registerAppTool,
RESOURCE_MIME_TYPE,
} from '@modelcontextprotocol/ext-apps/server';
import { z } from 'zod';
const server = new McpServer({ name: 'weather', version: '1.0.0' });
const resourceUri = 'ui://weather/v2/index.html';
registerAppResource(
server,
'Weather View',
resourceUri,
{ mimeType: RESOURCE_MIME_TYPE },
async () => ({
contents: [
{
uri: resourceUri,
mimeType: RESOURCE_MIME_TYPE,
text: await loadBuiltHtml(),
},
],
})
);
registerAppTool(
server,
'get-weather',
{
title: 'Get Weather',
description: 'Get current conditions and a five-day forecast for one location.',
inputSchema: {
location: z.string().describe('City and region, such as Chicago, IL'),
},
_meta: {
ui: {
resourceUri,
visibility: ['model', 'app'],
},
},
},
async ({ location }) => {
const forecast = await getForecast(location);
return {
content: [{ type: 'text', text: summarizeForecast(forecast) }],
structuredContent: forecast,
};
}
);
The contract has four checks:
- The URI starts with
ui://. - The tool URI exactly matches the registered resource URI.
resources/readreturns a complete HTML document for that URI.- The content MIME type is
text/html;profile=mcp-app.
The host may prefetch and cache the resource as soon as it sees the tool descriptor. Keep user data out of the HTML template and return it through tool results or app-only calls. Use a versioned URI such as ui://weather/v2/index.html for a breaking View change so a cached old template cannot interpret a new output shape.
UI-only resources may be discovered through tool metadata and omitted from resources/list. Do not assume a missing list entry means the resource is unreadable. The decisive check is resources/read with the exact resourceUri.
visibility controls three different catalogs
Visibility is easier to reason about when you separate the server catalog, agent catalog, and View call path.
| Boundary | What happens |
|---|---|
Raw MCP tools/list | The server returns its tool descriptors, including app-only helpers. |
| Host agent catalog | The host excludes tools whose visibility does not include model. |
View tools/call | The host allows calls only when visibility includes app and the tool belongs to the same server. |
This is why an app-only tool can appear in a protocol test but remain hidden from the model. The host filters the agent-facing catalog after it reads the server descriptor.
The supported values are:
| Visibility | Model can call | View can call | Typical use |
|---|---|---|---|
Omitted or ['model', 'app'] | Yes | Yes | Search, display, or refresh tools valid in both contexts |
['model'] | Yes | No | Conversation-only analysis or explanation |
['app'] | No | Yes | Pagination, polling, validation, draft saves, and confirmed writes |
Use explicit visibility once an app has more than one tool. The default is convenient, but it can expose low-level UI operations to the model and increase tool-selection noise.
App-only helpers usually omit resourceUri
An app-only helper does not need to launch another copy of the View. It can return data to the View that called it:
registerAppTool(
server,
'load-more-invoices',
{
title: 'Load More Invoices',
description: 'Load the next invoice page for the open invoice View.',
inputSchema: {
cursor: z.string(),
},
outputSchema: {
invoices: z.array(
z.object({
id: z.string(),
customer: z.string(),
total: z.number(),
})
),
nextCursor: z.string().optional(),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
},
_meta: {
ui: {
visibility: ['app'],
},
},
},
async ({ cursor }) => {
const page = await listInvoices({ cursor });
return {
content: [{ type: 'text', text: `Loaded ${page.invoices.length} invoices.` }],
structuredContent: page,
};
}
);
The View calls it through the MCP Apps bridge:
import { useCallServerTool } from 'sunpeak';
import { useState } from 'react';
type Invoice = { id: string; customer: string; total: number };
type InvoicePage = { invoices: Invoice[]; nextCursor?: string };
export function InvoicePager({ initialPage }: { initialPage: InvoicePage }) {
const callServerTool = useCallServerTool();
const [page, setPage] = useState(initialPage);
const [loading, setLoading] = useState(false);
async function loadMore() {
if (!page.nextCursor || loading) return;
setLoading(true);
const result = await callServerTool({
name: 'load-more-invoices',
arguments: { cursor: page.nextCursor },
});
setLoading(false);
if (result?.isError || !result?.structuredContent) return;
const next = result.structuredContent as InvoicePage;
setPage({
invoices: [...page.invoices, ...next.invoices],
nextCursor: next.nextCursor,
});
}
return (
<button onClick={loadMore} disabled={!page.nextCursor || loading}>
{loading ? 'Loading' : 'Load more'}
</button>
);
}
Check the Host’s serverTools capability before showing this button. A host may render MCP Apps but decline View-initiated server calls.
Server tools and View-provided tools run in opposite directions
Two APIs with similar names solve different jobs:
| Kind | Registered by | Called by | Example |
|---|---|---|---|
| App-visible server tool | MCP server | View through tools/call | Save a draft or fetch another page |
| View-provided tool | Code inside the iframe | Host | Read the current chart selection |
useCallServerTool() calls a server tool from the View. useAppTools() registers tools that the host can call inside the View. Setting _meta.ui.visibility: ['app'] applies to the first kind. It does not register a View-provided tool.
This distinction matters in tests. A serverTools simulation mock handles a View call to the MCP server. A View-provided tool needs a Host-to-View call test.
Visibility is not server authorization
The host must enforce the MCP Apps visibility rules, including blocking cross-server calls. Your server still owns the security decision.
For every app-visible tool:
- Authenticate the MCP request and validate the token audience.
- Check scopes, tenant, and record-level access.
- Validate all arguments even if the UI created them.
- Use idempotency keys for retried writes.
- Return
isError: truefor recoverable execution failures. - Log the user, tool, target, result, and idempotency key without logging tokens.
A caller can connect to an MCP server without using your expected Host or View. Metadata narrows what a compliant Host exposes, but it cannot replace checks in the handler.
For a confirmed write, put the preview tool in the model catalog and the commit tool in the app catalog. The commit handler must still verify that the approved target, payload, and user are valid.
Capability negotiation and text fallback
MCP Apps is optional. A Host advertises the io.modelcontextprotocol/ui extension and the MIME types it supports. A server should use the SDK’s capability helpers and fall back to an ordinary text tool when the Host cannot render text/html;profile=mcp-app.
That fallback is why a UI launcher should return useful content as well as structuredContent. The model and text-only clients need enough information to answer without the View.
Current Claude docs describe MCP Apps in Claude and ChatGPT from one codebase. Use registerAppTool(), registerAppResource(), and App.connect() so the MCP Apps SDK can normalize host transport and compatibility metadata. Hand-maintained platform aliases can drift away from _meta.ui.resourceUri.
The flat _meta['ui/resourceUri'] key is deprecated. Use nested _meta.ui.resourceUri as the source of truth. Current MCP Apps helpers normalize the legacy flat key for older Hosts.
The sunpeak tool file form
sunpeak maps a short resource name to the generated ui:// URI:
// 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 conditions and a five-day forecast for one location.',
annotations: {
readOnlyHint: true,
destructiveHint: false,
openWorldHint: true,
},
_meta: {
ui: {
visibility: ['model', 'app'],
},
},
};
export const schema = {
location: z.string().describe('City and region, such as Chicago, IL'),
};
export const outputSchema = {
location: z.string(),
temperature: z.number(),
conditions: z.string(),
};
type Args = z.infer<z.ZodObject<typeof schema>>;
export default async function (args: Args, extra: ToolHandlerExtra) {
const forecast = await getForecast(args.location, extra.signal);
return {
content: [{ type: 'text' as const, text: summarizeForecast(forecast) }],
structuredContent: forecast,
};
}
resource: 'weather' points to src/resources/weather/. sunpeak generates the resource URI and passes the standard metadata through its MCP server. App-only helpers omit resource and set visibility: ['app'].
Test the descriptor before the browser
Start with a protocol test because it isolates metadata and resource registration:
import { expect, test } from 'sunpeak/test';
test('weather tool links to a readable MCP App resource', async ({ mcp }) => {
const tools = await mcp.listTools();
const weather = tools.find((tool) => tool.name === 'get-weather');
const resourceUri = weather?._meta?.ui?.resourceUri;
expect(resourceUri).toMatch(/^ui:\/\//);
expect(weather?._meta?.ui?.visibility).toEqual(['model', 'app']);
const resources = await mcp.listResources();
const resource = resources.find((item) => item.uri === resourceUri);
expect(resource?.mimeType).toBe('text/html;profile=mcp-app');
const html = await mcp.readResource(resourceUri as string);
expect(html).toContain('<!doctype html>');
});
test('pagination helper is app-only and does not launch a View', async ({ mcp }) => {
const tools = await mcp.listTools();
const helper = tools.find((tool) => tool.name === 'load-more-invoices');
expect(helper?._meta?.ui?.visibility).toEqual(['app']);
expect(helper?._meta?.ui?.resourceUri).toBeUndefined();
});
In sunpeak, mcp.readResource(uri) returns the HTML string. Use mcp.listResources() for listed resource metadata. For a server that intentionally omits UI-only resources from resources/list, test the lower-level resources/read response instead.
Then run a Host-level E2E test. Prove the launcher appears in the model catalog, the app-only helper does not, and a button in the rendered View can still call the helper. This second layer tests Host filtering and same-server enforcement, which a raw MCP client does not cover.
Simulation fixtures make the View call deterministic:
{
"tool": "show-invoices",
"toolResult": {
"structuredContent": {
"invoices": [{ "id": "INV-1042", "customer": "Northwind", "total": 4200 }],
"nextCursor": "page-2"
}
},
"serverTools": {
"load-more-invoices": {
"content": [{ "type": "text", "text": "Loaded 1 invoice." }],
"structuredContent": {
"invoices": [{ "id": "INV-1043", "customer": "Contoso", "total": 1800 }]
}
}
}
}
Failure matrix
| Symptom | Contract to inspect | Likely fix |
|---|---|---|
| Tool runs but no View appears | resourceUri and resources/read | Match the exact ui:// URI and MIME type |
| View works after a hard refresh only | Cached resource and output version | Version the resource URI for breaking changes |
| Model calls pagination or validation | Helper visibility | Set visibility: ['app'] |
| View call is rejected | Host serverTools capability and helper visibility | Gate the control and include app |
| View can call an unintended tool | Tool visibility | Remove app from conversation-only tools |
Helper appears in raw tools/list | No defect | Verify the Host filters it from the agent catalog |
| Tool works in one Host only | Capability and compatibility metadata | Use SDK registration helpers and text fallback |
| UI loads but remote assets fail | Resource _meta.ui.csp | Put CSP origins on the resource content |
Release checklist
- Every launcher points at one registered, readable, versioned
ui://resource. - Every UI resource returns
text/html;profile=mcp-app. - Each tool has explicit visibility when the default would be broader than needed.
- App-only helpers omit
resourceUriunless they should launch a View. - Model-only tools reject calls from the View.
- Server handlers enforce auth and do not trust iframe arguments.
- UI launchers return concise text fallback content.
- Views check Host capabilities before enabling server-call controls.
- Protocol tests inspect raw metadata and HTML.
- Host tests cover model filtering, app calls, and cross-host behavior.
sunpeak can run these checks against replicated Claude and ChatGPT runtimes locally and in CI. Its file-based app framework generates the normal tool-to-resource link, while its protocol and Playwright fixtures let you inspect the descriptor, render the View, and exercise app-only calls without spending model credits.
Start with the MCP App framework when you are building a portable app, or use the testing framework to add metadata checks to an existing MCP server.
Get Started
npx sunpeak newFurther Reading
- MCP App conformance testing - verify metadata and resource links in CI
- MCP App tool result channels - content, structuredContent, and _meta
- Testing multi-tool MCP Apps - coordinate UI and backend tools
- MCP App capability detection and text fallbacks
- MCP App CSP domains belong on resource metadata
- MCP App framework
- ChatGPT App framework
- Claude Connector framework
- sunpeak tool _meta reference
- sunpeak tool and resource contract
- Stable MCP Apps specification
- McpUiToolMeta API reference
- Claude guide to cross-platform MCP Apps
Frequently Asked Questions
What is _meta.ui.resourceUri in an MCP App?
_meta.ui.resourceUri is the stable MCP Apps tool metadata field that points to the ui:// HTML resource a compatible host should render for a tool. The URI must match a registered resource whose MIME type is text/html;profile=mcp-app. A tool that does not launch a View can omit resourceUri.
What is _meta.ui.visibility in MCP Apps?
_meta.ui.visibility declares whether the model, the rendered app, or both may call a server tool. Use model for agent-visible tools and app for tools callable by the View on the same server connection. If omitted, visibility defaults to both model and app.
Does an app-only MCP tool appear in tools/list?
The MCP server can return the app-only descriptor in its raw tools/list response. The host must keep it out of the agent tool catalog because its visibility excludes model. This distinction lets developers inspect and test the descriptor while preventing the model from selecting the helper tool.
Does an app-only tool need resourceUri?
No. Add resourceUri when calling the tool should launch or associate a View. Pagination, validation, refresh, and commit helpers that only return data to an already-open View usually need visibility set to app but no resourceUri.
Is MCP App visibility an authorization control?
Visibility is a host-enforced routing and discovery control, not a replacement for server authorization. The server must still authenticate the user, check scopes and record access, validate input, and protect write actions against replay. A generic MCP client may call a server endpoint outside an MCP App host.
What is the difference between app-visible server tools and View-provided tools?
An app-visible server tool is registered by the MCP server and called from the View with tools/call. A View-provided tool is registered inside the iframe so the host can call into the View. They use opposite directions and solve different jobs.
Should I set legacy ui/resourceUri or OpenAI metadata by hand?
Use nested _meta.ui.resourceUri and _meta.ui.visibility as the source of truth. The flat ui/resourceUri key is deprecated. Current registerAppTool and registerAppResource helpers normalize host compatibility metadata, so manual duplicate aliases add drift risk.
How do I test MCP App resourceUri and visibility?
Inspect the raw tools/list descriptors, verify every UI launcher points to a readable ui:// resource with the MCP App HTML MIME type, and assert each helper has the intended visibility. Then run a host-level E2E test to prove app-only tools stay out of the model catalog and remain callable from the View.