Skip to main content
All posts

MCP App Resource Metadata: CSP, Permissions, and ChatGPT Widget Fields (August 2026)

Abe Wheeler
MCP AppsMCP App FrameworkMCP App TestingChatGPT AppsChatGPT App FrameworkChatGPT App TestingClaude ConnectorsClaude Connector FrameworkResource MetadataContent Security PolicySecurity
MCP App resource metadata tells hosts how to sandbox, secure, and present your app iframe.

MCP App resource metadata tells hosts how to sandbox, secure, and present your app iframe.

An MCP App can return valid HTML and still fail before React mounts. The host may block an API request, reject a sandbox domain, omit a browser permission, or frame the View differently than your design expects. Resource metadata is the contract that controls those decisions.

The current MCP Apps standard puts that contract under _meta.ui on a ui:// resource. The details matter because the metadata can appear at two protocol locations, its secure defaults deny external access, and some fields have different rules in Claude and ChatGPT.

TL;DR: Put resourceUri and visibility on tools. Put csp, permissions, domain, and prefersBorder on resources. The metadata on a resources/read content item overrides listing metadata. Declare only the origins and permissions the View needs, treat domain as host-specific, keep ChatGPT compatibility fields in sync, and test the protocol contract before debugging the UI.

Resource Metadata and Tool Metadata Have Different Jobs

An interactive MCP tool joins two protocol objects:

  1. The tool tells the host which UI resource belongs with its result.
  2. The resource supplies the HTML and tells the host how to run it.
FieldPlacementQuestion it answers
_meta.ui.resourceUriTool descriptorWhich ui:// View should render this tool result?
_meta.ui.visibilityTool descriptorCan the model, the app, or both call this tool?
_meta.ui.cspResource descriptor or content itemWhich external origins can the View reach?
_meta.ui.permissionsResource descriptor or content itemWhich browser capabilities does the View request?
_meta.ui.domainResource descriptor or content itemWhich stable sandbox origin should the host assign?
_meta.ui.prefersBorderResource descriptor or content itemDoes the View prefer host-provided border and background chrome?

This split is useful for security review. The model-facing tool description does not need browser policy, and the iframe resource does not decide who can call a server tool. See the tool metadata guide for resourceUri, visibility, and app-only tools.

The Current MCP App Resource Shape

The stable MCP Apps View protocol uses HTML with the text/html;profile=mcp-app MIME type. A complete resource policy can include four CSP lists, four browser permissions, a domain, and a border hint:

import type { ResourceConfig } from 'sunpeak';

export const resource: ResourceConfig = {
  title: 'Customer dashboard',
  description: 'Review account health and recent customer activity',
  mimeType: 'text/html;profile=mcp-app',
  _meta: {
    ui: {
      csp: {
        connectDomains: [
          'https://api.example.com',
          'wss://events.example.com',
        ],
        resourceDomains: ['https://cdn.example.com'],
        frameDomains: [],
        baseUriDomains: [],
      },
      permissions: {
        clipboardWrite: {},
      },
      prefersBorder: true,
    },
  },
};

All fields are optional. Omission is meaningful: no external connections, resources, or nested frames are allowed by default; only same-origin base URLs are allowed; no extra browser permissions are requested; and the host chooses its default origin and border treatment.

Metadata Can Appear in Two Protocol Responses

The MCP Apps specification lets a server return UIResourceMeta in two places:

LocationBest use
Resource descriptor in resources/listStatic policy that a host can inspect during discovery
Content item in resources/readThe policy applied to the fetched HTML, including request-specific values

When both contain _meta.ui, the content-item value takes precedence. Hosts must check the content item first and fall back to the listing descriptor.

That rule prevents a subtle production bug. A server can advertise one CSP during discovery but return a different policy with the HTML. If the content item accidentally omits an API origin, the listing entry does not merge it back in. The content-item _meta.ui object wins as a whole.

For static apps, return the same policy in both places. That gives the host an early reviewable descriptor and keeps the rendered policy identical. For dynamic policy, generate the content-item object deliberately and test every branch.

sunpeak co-locates resource metadata with the resource component and sends the resolved metadata in both resources/list and resources/read. That keeps a single ResourceConfig as the source of truth.

CSP Has Four Separate Origin Lists

Resource CSP is an allowlist. Each field maps to a different browser directive, so adding an origin to the wrong list does not grant the access you expected.

FieldBrowser behaviorSecure default
connectDomainsfetch, XHR, EventSource, and WebSocket through connect-srcNo external connections
resourceDomainsScripts, styles, images, fonts, and mediaNo external resources
frameDomainsNested iframes through frame-srcframe-src 'none'
baseUriDomainsURLs allowed in an HTML <base> elementbase-uri 'self'

Declare origins, not paths. Use https://api.example.com, not https://api.example.com/v1/orders. Include wss:// separately when the app opens a WebSocket. The standard supports wildcard subdomains such as https://*.example.com, but a short explicit list is easier to audit.

baseUriDomains deserves special attention because a <base> element changes how every relative URL resolves. Most bundled MCP Apps do not need one, so an empty list or the same-origin default is safer.

csp: {
  connectDomains: ['https://api.example.com', 'wss://events.example.com'],
  resourceDomains: ['https://cdn.example.com'],
  frameDomains: [],
  baseUriDomains: [],
}

A host may make this policy stricter, but it must not silently allow undeclared external origins. Claude currently restricts frameDomains pending security review. ChatGPT also treats nested frames as a higher-review feature, so prefer a host-mediated external link when an embed is optional.

CSP and CORS Solve Different Problems

CSP answers, “May this View try to contact that origin?” CORS answers, “Will that server accept this browser origin?” You often need both.

Suppose a View fetches https://api.example.com/orders:

  1. Add https://api.example.com to connectDomains so the iframe can send the request.
  2. Configure the API to return an Access-Control-Allow-Origin value that accepts the View’s sandbox origin.
  3. Keep authentication and authorization on the API or MCP server. CSP is not an auth control.

If the API accepts Access-Control-Allow-Origin: *, a stable sandbox origin may be unnecessary. If the API uses an origin allowlist, _meta.ui.domain can give the View a deterministic origin. That field has host-specific rules, covered below.

Do not put bearer tokens or long-lived API keys in the HTML resource. Keep sensitive work behind MCP tools where the server can authenticate the user, enforce scopes, rate-limit calls, and redact logs. Direct browser calls are most useful for public assets, low-risk APIs, or real-time streams designed for browser clients.

Permissions Are Requests, Not Grants

The standard permission object supports four fields:

FieldBrowser permission policyTypical use
cameracameraPhoto or video capture
microphonemicrophoneVoice input or recording
geolocationgeolocationMaps and nearby search
clipboardWriteclipboard-writeCopy buttons and exports

Each value is an empty object because presence requests the capability:

permissions: {
  microphone: {},
  clipboardWrite: {},
}

The host may add iframe permission-policy directives, show a prompt, deny the request, or ignore a permission it does not support. Your UI still needs feature detection and a denial state:

async function startRecording() {
  if (!navigator.mediaDevices?.getUserMedia) {
    setError('Microphone access is not available in this host.');
    return;
  }

  try {
    const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
    beginRecording(stream);
  } catch {
    setError('Microphone access was denied.');
  }
}

Host support is part of the contract. Claude’s current mobile WebViews do not allow camera, microphone, or location access, even when metadata requests them. Build a fallback such as file upload, typed input, or a server-side tool, then test it in a mobile host profile.

ui.domain Is Host-Specific

_meta.ui.domain requests a stable sandbox origin. It is useful when an OAuth redirect, CORS allowlist, or API-key restriction needs a predictable origin. It is not a general URL for your MCP server or product site.

HostCurrent rule
ClaudeExact {hash}.claudemcpcontent.com, where hash is the first 32 hex characters of the SHA-256 digest of the full connector URL
ChatGPTA unique dedicated origin is required when submitting a plugin with UI; ChatGPT otherwise defaults to its shared sandbox origin
Other MCP App hostsHost-defined; omit the field unless the host documents a format

Claude validates the domain against the full connector URL. A trailing slash, path change, or different URL string changes the hash and can produce Invalid ui.domain format or ui.domain mismatch. Compute it from the exact production connector URL.

A cross-host server cannot safely send one hard-coded value to every host. sunpeak supports a map keyed by MCP clientInfo.name and resolves it before returning the resource:

import type { ResourceConfig } from 'sunpeak';
import { computeClaudeDomain, computeChatGPTDomain } from 'sunpeak/mcp';

const serverUrl = 'https://mcp.example.com/mcp';

export const resource: ResourceConfig = {
  title: 'Customer dashboard',
  description: 'Review customer account health',
  mimeType: 'text/html;profile=mcp-app',
  _meta: {
    ui: {
      csp: {
        connectDomains: ['https://api.example.com'],
      },
      domain: {
        claude: computeClaudeDomain(serverUrl),
        'openai-mcp': computeChatGPTDomain(serverUrl),
      },
      prefersBorder: true,
    },
  },
};

The current sunpeak production server can also compute the host default when it knows the public server URL. If you register resources by hand, inspect clientInfo.name and return the correct string in resources/read.

prefersBorder Is a Hint With Visible Consequences

prefersBorder asks the host to add or omit a visible boundary and background around the View:

_meta: {
  ui: {
    prefersBorder: false,
  },
}

Set an explicit value because host defaults differ. Claude currently renders an unspecified View borderless on web and bordered on mobile. A borderless View runs closer to the container edge, so it must honor hostContext.safeAreaInsets. A bordered View may receive host padding, but the component should still handle narrow widths and fullscreen mode.

Treat the value as a preference. The host owns its chrome and can apply a different presentation. Test both bordered and borderless states so spacing, focus rings, and backgrounds remain usable.

ChatGPT Compatibility Fields in 2026

ChatGPT now documents the standard nested MCP Apps fields as the preferred resource metadata. Several OpenAI-specific keys remain for compatibility or behavior that the standard does not express.

ChatGPT fieldStandard fieldCurrent use
_meta["openai/widgetCSP"]_meta.ui.cspLegacy CSP mirror; still owns redirect_domains
_meta["openai/widgetDomain"]_meta.ui.domainLegacy dedicated-origin alias
_meta["openai/widgetPrefersBorder"]_meta.ui.prefersBorderLegacy border alias
_meta["openai/widgetDescription"]Resource description, with different model-facing behaviorHelps ChatGPT avoid repeating what the component already shows

Use _meta.ui.csp as the primary policy for new cross-host apps. Add _meta["openai/widgetCSP"] when an older ChatGPT path expects it or when the View calls window.openai.openExternal() for trusted destinations. The standard has no redirect allowlist, so redirect_domains remains ChatGPT-specific.

_meta: {
  ui: {
    csp: {
      connectDomains: ['https://api.example.com'],
      resourceDomains: ['https://cdn.example.com'],
    },
    prefersBorder: true,
  },
  'openai/widgetCSP': {
    connect_domains: ['https://api.example.com'],
    resource_domains: ['https://cdn.example.com'],
    redirect_domains: ['https://app.example.com'],
  },
  'openai/widgetPrefersBorder': true,
  'openai/widgetDescription':
    'Shows customer health, alerts, and recent activity.',
}

If you publish both shapes, generate them from one policy object or assert both in tests. A stale compatibility allowlist can make a View work in one host and fail in ChatGPT.

Version Resource URIs When the Contract Changes

Hosts may prefetch and cache UI resources because tool metadata points to the resource before a tool call. Keep the HTML and its metadata versioned together.

If a release changes the bundle, CSP, permission set, stable-origin assumptions, or bridge behavior, use a new URI such as:

ui://customer-dashboard/v4/index.html

Then update the tool’s _meta.ui.resourceUri in the same release. During rollback, keep the prior resource available long enough for conversations that still reference it. The resource caching guide covers version changes and rollout order in detail.

Test the Protocol Contract First

Start with a protocol test that inspects the descriptor. sunpeak mirrors a resource’s ResourceConfig metadata into the listing and content item, so listResources() catches the configured policy without parsing HTML:

import { test, expect } from 'sunpeak/test';

test('dashboard resource exposes its reviewed policy', async ({ mcp }) => {
  const { resources } = await mcp.listResources();
  const dashboard = resources.find(
    (resource) => resource.uri === 'ui://customer-dashboard'
  );

  expect(dashboard?.mimeType).toBe('text/html;profile=mcp-app');
  expect(dashboard?._meta?.ui).toMatchObject({
    csp: {
      connectDomains: ['https://api.example.com'],
      resourceDomains: ['https://cdn.example.com'],
      frameDomains: [],
      baseUriDomains: [],
    },
    permissions: {
      clipboardWrite: {},
    },
    prefersBorder: true,
  });
});

Add a tool assertion in the same suite so the link cannot drift:

test('dashboard tool points at the dashboard resource', async ({ mcp }) => {
  const { tools } = await mcp.listTools();
  const dashboard = tools.find((tool) => tool.name === 'show-dashboard');

  expect(dashboard?._meta?.ui).toMatchObject({
    resourceUri: 'ui://customer-dashboard',
  });
});

Protocol assertions catch missing fields and wrong placement. Browser tests prove enforcement and fallback behavior. Cover these paths in every target host profile:

  • Allowed API, WebSocket, asset, and font origins load successfully.
  • An undeclared request is blocked and does not leak data.
  • The API accepts the actual sandbox Origin header and rejects unapproved origins.
  • Permission success, user denial, missing browser API, and mobile fallback all render usable states.
  • The View works with and without host border chrome, at 320px, and in each supported display mode.
  • Claude receives its hash-derived domain, while ChatGPT receives its own dedicated origin.
  • External links use host APIs and the right ChatGPT redirect_domains entry where needed.

With sunpeak, the local Inspector can reproduce ChatGPT and Claude host contexts, and the same tests can run in CI without paid host accounts or AI credits. Use live-host smoke tests for the parts a replica cannot prove, such as production plugin review, Claude’s domain validation, real permission prompts, and host policy changes.

Production Review Checklist

Before shipping the resource, verify:

  • Every UI tool points to an existing, versioned ui:// resource.
  • The resource MIME type is text/html;profile=mcp-app.
  • Listing and content-item metadata agree unless an override is intentional.
  • Each CSP list contains only origins the built View actually uses.
  • baseUriDomains and frameDomains are empty unless the HTML needs them.
  • The API’s CORS policy accepts the real sandbox origin.
  • Requested permissions have tested denial and unsupported-host fallbacks.
  • domain matches each host’s documented format and exact production connector URL.
  • prefersBorder is explicit, and both presentation states remain usable.
  • Standard and ChatGPT compatibility metadata stay synchronized.
  • Protocol, browser, mobile, and narrow live-host smoke tests pass.

Resource metadata is small, but it defines the iframe’s operating limits. Review it like an API schema: keep one source of truth, version changes with the HTML, assert the serialized protocol shape, and verify enforcement in the hosts you support.

Start with npx sunpeak new, declare the narrowest resource policy that works, then run the Inspector and automated tests before connecting the server to ChatGPT or Claude.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

What is MCP App resource metadata?

MCP App resource metadata is the _meta.ui object attached to a ui:// resource descriptor or resource content item. It tells the host how to secure and present the rendered View, including allowed network and asset origins, requested browser permissions, a host-specific stable sandbox domain, and a border preference.

Where should I put _meta.ui resource metadata?

You can put _meta.ui on the resources/list descriptor, the contents item returned by resources/read, or both. Content-item metadata takes precedence when both exist. Put static metadata at listing level so hosts can review it during discovery, and make sure the content item contains the policy that must apply to the rendered View.

What is the difference between MCP App tool metadata and resource metadata?

Tool metadata connects a tool to a View and controls tool visibility. Put resourceUri and visibility under the tool _meta.ui object. Resource metadata controls the View sandbox and presentation. Put csp, permissions, domain, and prefersBorder under the resource _meta.ui object.

What CSP fields can an MCP App resource declare?

The standard MCP Apps CSP object supports connectDomains for fetch, XHR, and WebSocket; resourceDomains for scripts, styles, images, fonts, and media; frameDomains for nested iframes; and baseUriDomains for HTML base URLs. Omitted external domains stay blocked. Hosts may restrict the policy further but must not allow undeclared origins.

What browser permissions can an MCP App request?

The MCP Apps resource metadata can request camera, microphone, geolocation, and clipboardWrite. A declaration only asks the host to enable the related browser permission policy. The host, browser, or user can still deny it, and Claude mobile currently does not allow camera, microphone, or location access in MCP Apps.

What does _meta.ui.domain mean for MCP Apps?

_meta.ui.domain asks the host for a stable sandbox origin. Its format is host-specific, so it is not simply your MCP server or product website domain. Claude requires the first 32 hexadecimal characters of the SHA-256 hash of the full connector URL followed by .claudemcpcontent.com. ChatGPT requires a unique dedicated origin for a submitted plugin with UI.

Do I still need openai/widgetCSP for a ChatGPT App?

Use standard _meta.ui.csp for new ChatGPT App security metadata. The legacy openai/widgetCSP key remains useful as a compatibility mirror, and its redirect_domains field is still required to allow trusted window.openai.openExternal destinations because standard _meta.ui.csp has no redirect equivalent.

How do I test MCP App resource metadata?

Use protocol tests to inspect resources/list and assert exact metadata, then use browser tests in each target host profile to verify allowed requests, blocked requests, permission denial, stable-origin CORS, borders, and mobile behavior. sunpeak can run these checks locally and in CI without spending ChatGPT or Claude credits.