Skip to main content
All posts

MCP App Permissions: Camera, Microphone, Geolocation, and Clipboard

Abe Wheeler
MCP AppsMCP App FrameworkMCP App TestingChatGPT AppsChatGPT App FrameworkChatGPT App TestingClaude ConnectorsClaude Connector FrameworkMCP App PermissionsResource Metadata
MCP App permissions are resource metadata that let hosts prepare iframe access for camera, microphone, geolocation, and clipboard workflows.

MCP App permissions are resource metadata that let hosts prepare iframe access for camera, microphone, geolocation, and clipboard workflows.

Most MCP App permission bugs look strange at first because the code is normal browser code. getUserMedia() works in a standalone tab. navigator.clipboard.writeText() works on localhost. Geolocation works in a plain React app.

Then the same UI runs inside ChatGPT, Claude, or another MCP App host and the permission prompt never appears.

That happens because an MCP App resource is not a normal top-level page. It is sandboxed iframe content controlled by the host. The browser API still matters, but the host has to allow the feature before the iframe can use it.

TL;DR: Declare camera, microphone, geolocation, and clipboard access on the MCP App resource with _meta.ui.permissions. Treat that declaration as a request, not a guarantee. The app still needs feature detection, user-denied states, manual fallbacks, and cross-host tests. In sunpeak, keep the permission metadata next to the resource component, then use the inspector and Playwright tests to cover allowed, denied, unavailable, and fallback paths before shipping.

Why This Is the Search Gap

The current MCP App content map covers first apps, resources, tool results, CSP, host context, display modes, OAuth, app actions, and testing. Permissions show up inside resource metadata, but developers usually search for the concrete browser capability they are trying to use:

  • MCP App camera permission
  • ChatGPT App microphone access
  • MCP App geolocation iframe
  • MCP App clipboard write
  • _meta.ui.permissions example
  • camera works in browser but not ChatGPT App
  • MCP App iframe allow attribute

Those searches are technical and high intent. The builder already has a rendered resource and a real product workflow. They need to know where the permission belongs, what the host can block, and how to make the UI degrade cleanly.

The Permission Model

An MCP App has three permission layers:

LayerWho controls itWhat it decides
Resource metadataYour MCP serverWhich browser capabilities the resource requests
Host iframe policyChatGPT, Claude, or another hostWhich requested capabilities the iframe can attempt to use
Browser and user permissionBrowser, OS, user, workspace policyWhether the API call actually succeeds

All three have to line up.

The standard MCP Apps permissions type currently includes:

PermissionBrowser featureTypical API
cameracameranavigator.mediaDevices.getUserMedia({ video: true })
microphonemicrophonenavigator.mediaDevices.getUserMedia({ audio: true })
geolocationgeolocationnavigator.geolocation.getCurrentPosition()
clipboardWriteclipboard-writenavigator.clipboard.writeText()

The MCP Apps API reference describes these as sandbox permissions requested by the UI resource. Hosts may honor them by setting iframe allow attributes, and apps should still use JavaScript feature detection. That last sentence is the one to build around.

Put Permissions on the Resource

Permissions belong on the resource because they affect the iframe that renders your HTML. Tool metadata routes the host to a resource. Resource metadata tells the host how to frame that resource.

In a low-level MCP Apps server, the permission metadata appears on the resource contents:

import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';

registerAppResource(
  server,
  'Receipt Scanner',
  'ui://receipt/scanner.html',
  {
    description: 'Scan receipts with the device camera.',
  },
  async () => ({
    contents: [
      {
        uri: 'ui://receipt/scanner.html',
        mimeType: RESOURCE_MIME_TYPE,
        text: receiptScannerHtml,
        _meta: {
          ui: {
            permissions: {
              camera: {},
            },
          },
        },
      },
    ],
  })
);

In a sunpeak project, put the same intent in the resource config next to the React resource:

import type { ResourceConfig } from 'sunpeak';

export const resource: ResourceConfig = {
  description: 'Scan receipts with the device camera.',
  _meta: {
    ui: {
      permissions: {
        camera: {},
      },
    },
  },
};

export function ReceiptScanner() {
  return <ScannerView />;
}

That placement makes review easier. If a component calls camera, microphone, geolocation, or clipboard APIs, the resource file should also declare the matching permission.

Permissions Are Not Capabilities

Do not treat _meta.ui.permissions as a host capability check.

The permission declaration says, “this resource wants to use this browser feature.” It does not say:

  • The host supports that feature.
  • The host supports it on this platform.
  • The user has granted the browser permission.
  • The operating system allows the host app to use that device.
  • The workspace policy allows it.

Your UI still needs runtime checks:

function canUseCamera() {
  return typeof navigator !== 'undefined' && Boolean(navigator.mediaDevices?.getUserMedia);
}

Feature detection should control the UI state. Show the scan button only when the basic API exists. Handle failure after click because the user or host can still deny the request.

Camera and Microphone

Camera and microphone access should always start from an explicit user action. A button click gives the user context and matches browser expectations for sensitive prompts.

import { useState } from 'react';

export function CaptureButton() {
  const [error, setError] = useState<string | null>(null);
  const [stream, setStream] = useState<MediaStream | null>(null);

  async function startCamera() {
    setError(null);

    if (!navigator.mediaDevices?.getUserMedia) {
      setError('Camera access is not available here. Upload a file instead.');
      return;
    }

    try {
      const nextStream = await navigator.mediaDevices.getUserMedia({
        video: { facingMode: 'environment' },
      });
      setStream(nextStream);
    } catch (err) {
      if (err instanceof DOMException && err.name === 'NotAllowedError') {
        setError('Camera access was blocked. Upload a photo instead.');
        return;
      }

      if (err instanceof DOMException && err.name === 'NotFoundError') {
        setError('No camera was found. Upload a photo instead.');
        return;
      }

      setError('Camera did not start. Upload a photo instead.');
    }
  }

  function stopCamera() {
    stream?.getTracks().forEach((track) => track.stop());
    setStream(null);
  }

  return (
    <div>
      <button type="button" onClick={stream ? stopCamera : startCamera}>
        {stream ? 'Stop camera' : 'Scan receipt'}
      </button>
      {error ? <p role="alert">{error}</p> : null}
    </div>
  );
}

The fallback is not optional. Native host apps, mobile browsers, enterprise policies, and OS privacy settings can all produce different behavior for the same resource metadata.

For microphone access, use the same shape with audio constraints:

await navigator.mediaDevices.getUserMedia({ audio: true });

If the microphone supports a core workflow, such as dictation or voice notes, also offer text input. A user who cannot grant microphone access should still finish the task.

Geolocation

Geolocation is rarely the only way to solve a workflow. Use it when it saves real work, then offer manual entry nearby.

Good uses:

  • “Find nearby pickup points.”
  • “Use my current location for mileage.”
  • “Show local availability.”

Weak uses:

  • “Personalize a generic dashboard.”
  • “Guess a region when the user can type a ZIP code.”
  • “Collect exact coordinates for analytics.”

Keep the UI honest:

function getLocation() {
  if (!navigator.geolocation) {
    setMode('manual');
    return;
  }

  navigator.geolocation.getCurrentPosition(
    (position) => {
      setCoords({
        latitude: position.coords.latitude,
        longitude: position.coords.longitude,
      });
    },
    () => {
      setMode('manual');
    },
    {
      enableHighAccuracy: false,
      timeout: 8000,
      maximumAge: 300000,
    }
  );
}

Ask for the lowest precision that works. If a ZIP code or city is enough, do not require exact latitude and longitude. If the model does not need exact location, keep coordinates out of content and model-visible structuredContent. Put only the user-approved summary in model context, such as “Using Chicago, IL as the search area.”

Clipboard Write

Clipboard write is the lowest-risk permission in this list, but it still needs a fallback. Some hosts or browsers require a user gesture, some block clipboard access in iframes, and some allow writeText() only on secure origins.

import { useState } from 'react';

export function CopyToken({ token }: { token: string }) {
  const [copied, setCopied] = useState(false);
  const [manual, setManual] = useState(false);

  async function copy() {
    if (!navigator.clipboard?.writeText) {
      setManual(true);
      return;
    }

    try {
      await navigator.clipboard.writeText(token);
      setCopied(true);
    } catch {
      setManual(true);
    }
  }

  return (
    <div>
      <button type="button" onClick={copy}>
        {copied ? 'Copied' : 'Copy'}
      </button>
      {manual ? (
        <label>
          Copy manually
          <input readOnly value={token} onFocus={(event) => event.currentTarget.select()} />
        </label>
      ) : null}
    </div>
  );
}

Do not put secrets in a copy field unless the user is already authorized to see them. Clipboard convenience is not an access-control layer.

How Hosts Translate Permissions

Browser permissions inside iframes are controlled by Permissions Policy. For an embedded frame, the host often needs to add an allow attribute such as camera, microphone, geolocation, or clipboard-write.

The MCP Apps bridge includes a helper that maps McpUiResourcePermissions to iframe allow directives, for example microphone; clipboard-write. You usually do not call that helper in app code because the host owns the iframe. It is still useful to understand the mapping because it explains why resource metadata matters.

If your standalone page works but the MCP App iframe fails, debug in this order:

  1. Did the resource contents include _meta.ui.permissions?
  2. Did the rendered host iframe receive the matching allow policy?
  3. Is the app running in a secure context?
  4. Does the browser API exist in this host surface?
  5. Did the user, OS, browser, or workspace deny the permission?
  6. Does the UI have a fallback path?

That order saves time because it starts at the MCP boundary before jumping into React.

ChatGPT Compatibility Notes

For new MCP Apps, use standard _meta.ui.permissions and _meta.ui.csp fields first.

ChatGPT also supports OpenAI-specific compatibility metadata for some resource settings, such as _meta["openai/widgetCSP"], _meta["openai/widgetDomain"], and _meta["openai/widgetPrefersBorder"]. Those are useful when you need ChatGPT-specific behavior, but they are not a replacement for the standard MCP App permissions field.

The practical split:

NeedPrefer
Request camera, microphone, geolocation, or clipboard write_meta.ui.permissions
Allow fetch, image, script, font, or iframe origins_meta.ui.csp
Support ChatGPT-only CSP compatibility detailsAdd _meta["openai/widgetCSP"] as an alias where needed
Open external links in ChatGPT with redirect allowlistsUse the ChatGPT-specific redirect metadata required by that path

Keep host-specific metadata additive. Your portable resource contract should stay readable without knowing every ChatGPT compatibility key.

Testing Permission Metadata

Start with a protocol-level test. You want a fast check that the resource declares the permission the UI uses.

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

test('receipt scanner declares camera permission', async ({ mcp }) => {
  const resources = await mcp.listResources();
  const scanner = resources.find((resource) => resource.uri === 'ui://receipt/scanner.html');

  expect(scanner?._meta?.ui?.permissions).toMatchObject({
    camera: {},
  });
});

Then test the component states. Mock the browser API, render the resource, and assert that the UI does the right thing when access is unavailable or denied.

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { CaptureButton } from './CaptureButton';

test('falls back when camera permission is denied', async () => {
  Object.defineProperty(navigator, 'mediaDevices', {
    configurable: true,
    value: {
      getUserMedia: vi.fn().mockRejectedValue(new DOMException('Denied', 'NotAllowedError')),
    },
  });

  render(<CaptureButton />);
  await userEvent.click(screen.getByRole('button', { name: /scan receipt/i }));

  expect(screen.getByRole('alert')).toHaveTextContent('Upload a photo instead');
});

Finally, test the rendered resource in a host-like frame. Use simulation files for the states that do not require a real device, then run a narrow manual or live-host pass for actual camera and microphone prompts.

In sunpeak, the inspector gives you the local loop:

pnpm dev

or, for an existing server:

npx sunpeak inspect --server http://localhost:8000/mcp

Use the inspector to switch host shells, themes, display modes, and viewport widths while testing the same resource. That catches layout and fallback bugs before you spend time in a real host.

Test Matrix

Permission workflows need a smaller matrix than most teams expect, but it has to include denial and absence.

CaseWhat to prove
Metadata presentResource declares exactly the permissions it needs
API unavailableUI offers a manual fallback
User denies permissionUI explains the fallback without crashing
Permission succeedsUI starts the device, writes clipboard, or reads location
CleanupCamera and microphone tracks stop on close or teardown
Mobile widthControls remain usable without hover
Different host shellThe same fallback works in ChatGPT and Claude modes
Secure context issueUI does not blame the user for a host or deployment problem

The cleanup row matters for camera and microphone. If the host removes your iframe or the user closes the view, stop tracks and abort any pending work. Tie that cleanup to React unmount and, when your framework exposes it, the MCP App teardown event.

Common Mistakes

The biggest mistake is declaring permissions on the tool. The tool can point at a UI resource, but it cannot grant browser features to the iframe. Move camera, microphone, geolocation, and clipboard declarations to resource metadata.

Another common mistake is treating permission failure as an error page. A denied camera prompt is a normal product state. A blocked geolocation request is a normal product state. Design those paths as part of the workflow.

Also avoid broad collection. If the app only needs a city, do not request exact coordinates. If the app only needs one receipt photo, do not keep a camera stream running after capture. If the model only needs a summary, do not put raw location or device metadata in model-visible fields.

A Practical Build Checklist

Use this checklist when adding a browser permission to an MCP App:

  1. Put the permission in _meta.ui.permissions on the resource.
  2. Keep related CSP domains in _meta.ui.csp, not in permissions.
  3. Trigger the browser API from a user action.
  4. Check whether the API exists before calling it.
  5. Handle denied, unavailable, not found, timeout, and generic failure states.
  6. Provide a manual or upload fallback.
  7. Stop camera and microphone tracks during close, unmount, and teardown.
  8. Keep sensitive data out of model-visible content and structuredContent unless the model needs it.
  9. Add metadata, component, and inspector tests.
  10. Run a narrow real-host pass for actual device prompts before submission.

Permissions are a host contract and a product workflow. Treat both parts seriously and the feature will feel normal even when the host says no.

If you are building permission-heavy MCP Apps, start with sunpeak so the resource metadata, host iframe, inspector, and tests live in one project. Run npx sunpeak new for a new app, or use the MCP App inspector to test an existing server before you rebuild it.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

How do MCP App permissions work?

MCP App permissions are declared on the rendered resource with _meta.ui.permissions. They tell the host which browser capabilities the iframe may need, such as camera, microphone, geolocation, or clipboard write. The host may translate that declaration into iframe allow attributes or other host policy, but the app still needs JavaScript feature detection and user-denied fallbacks.

Where do I declare camera or microphone access in an MCP App?

Declare camera or microphone access on the resource metadata, not on the tool. In the standard MCP Apps shape, use _meta.ui.permissions with camera: {} or microphone: {} on the resource contents. In a sunpeak resource, put the same permissions in the exported resource config next to the React component.

Does _meta.ui.permissions guarantee that the browser permission prompt will work?

No. _meta.ui.permissions is a request to the host. The host may honor it, ignore it, block it by workspace policy, or support it only on some platforms. The user can also deny the browser permission. Always check APIs such as navigator.mediaDevices, navigator.geolocation, navigator.clipboard, and document permissions behavior before relying on them.

What permissions can an MCP App request today?

The MCP Apps resource permissions type currently includes camera, microphone, geolocation, and clipboardWrite. Those map to browser Permission Policy features such as camera, microphone, geolocation, and clipboard-write. Treat the list as host-dependent because support can differ by host, browser, native app, operating system, and workspace policy.

How should a ChatGPT App handle camera access on mobile?

Treat camera access as optional even when it works on desktop. Declare _meta.ui.permissions.camera, use navigator.mediaDevices.getUserMedia only after a user action, handle NotAllowedError and NotFoundError, and provide an upload, paste, manual entry, or server-side fallback when the host or mobile app blocks the camera.

Should I use geolocation in an MCP App?

Use geolocation only when the location materially improves the workflow and the user has a clear reason to grant it. Offer manual entry as the default fallback, keep precision low when exact coordinates are not needed, and avoid sending location to the model unless the model needs it to answer the user.

How do I test MCP App permissions?

Test permissions at three levels: metadata tests that assert _meta.ui.permissions is present on the resource, component tests that mock browser APIs and denied states, and inspector or Playwright tests that render allowed, denied, unavailable, and fallback states across host shells, themes, display modes, and mobile widths.