Skip to main content
All posts

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

Abe Wheeler
MCP AppsMCP App FrameworkMCP App TestingChatGPT AppsChatGPT App FrameworkChatGPT App TestingClaude ConnectorsClaude Connector FrameworkTool MetadataresourceUri
MCP App tool metadata links model-callable tools to sandboxed UI resources and controls which tools the app can call.

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:

FieldQuestion it answersDefault
_meta.ui.resourceUriWhich ui:// resource should the host render?No View
_meta.ui.visibilityMay 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.

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:

  1. The URI starts with ui://.
  2. The tool URI exactly matches the registered resource URI.
  3. resources/read returns a complete HTML document for that URI.
  4. 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.

BoundaryWhat happens
Raw MCP tools/listThe server returns its tool descriptors, including app-only helpers.
Host agent catalogThe host excludes tools whose visibility does not include model.
View tools/callThe 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:

VisibilityModel can callView can callTypical use
Omitted or ['model', 'app']YesYesSearch, display, or refresh tools valid in both contexts
['model']YesNoConversation-only analysis or explanation
['app']NoYesPagination, 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:

KindRegistered byCalled byExample
App-visible server toolMCP serverView through tools/callSave a draft or fetch another page
View-provided toolCode inside the iframeHostRead 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: true for 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

SymptomContract to inspectLikely fix
Tool runs but no View appearsresourceUri and resources/readMatch the exact ui:// URI and MIME type
View works after a hard refresh onlyCached resource and output versionVersion the resource URI for breaking changes
Model calls pagination or validationHelper visibilitySet visibility: ['app']
View call is rejectedHost serverTools capability and helper visibilityGate the control and include app
View can call an unintended toolTool visibilityRemove app from conversation-only tools
Helper appears in raw tools/listNo defectVerify the Host filters it from the agent catalog
Tool works in one Host onlyCapability and compatibility metadataUse SDK registration helpers and text fallback
UI loads but remote assets failResource _meta.ui.cspPut 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 resourceUri unless 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

Documentation →
npx sunpeak new

Further Reading

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.