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

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.permissionsexample- 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:
| Layer | Who controls it | What it decides |
|---|---|---|
| Resource metadata | Your MCP server | Which browser capabilities the resource requests |
| Host iframe policy | ChatGPT, Claude, or another host | Which requested capabilities the iframe can attempt to use |
| Browser and user permission | Browser, OS, user, workspace policy | Whether the API call actually succeeds |
All three have to line up.
The standard MCP Apps permissions type currently includes:
| Permission | Browser feature | Typical API |
|---|---|---|
camera | camera | navigator.mediaDevices.getUserMedia({ video: true }) |
microphone | microphone | navigator.mediaDevices.getUserMedia({ audio: true }) |
geolocation | geolocation | navigator.geolocation.getCurrentPosition() |
clipboardWrite | clipboard-write | navigator.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:
- Did the resource contents include
_meta.ui.permissions? - Did the rendered host iframe receive the matching allow policy?
- Is the app running in a secure context?
- Does the browser API exist in this host surface?
- Did the user, OS, browser, or workspace deny the permission?
- 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:
| Need | Prefer |
|---|---|
| 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 details | Add _meta["openai/widgetCSP"] as an alias where needed |
| Open external links in ChatGPT with redirect allowlists | Use 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.
| Case | What to prove |
|---|---|
| Metadata present | Resource declares exactly the permissions it needs |
| API unavailable | UI offers a manual fallback |
| User denies permission | UI explains the fallback without crashing |
| Permission succeeds | UI starts the device, writes clipboard, or reads location |
| Cleanup | Camera and microphone tracks stop on close or teardown |
| Mobile width | Controls remain usable without hover |
| Different host shell | The same fallback works in ChatGPT and Claude modes |
| Secure context issue | UI 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:
- Put the permission in
_meta.ui.permissionson the resource. - Keep related CSP domains in
_meta.ui.csp, not in permissions. - Trigger the browser API from a user action.
- Check whether the API exists before calling it.
- Handle denied, unavailable, not found, timeout, and generic failure states.
- Provide a manual or upload fallback.
- Stop camera and microphone tracks during close, unmount, and teardown.
- Keep sensitive data out of model-visible
contentandstructuredContentunless the model needs it. - Add metadata, component, and inspector tests.
- 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
npx sunpeak newFurther Reading
- MCP App resource metadata - CSP, permissions, domains, and borders
- MCP App iframe sandbox - origins, CSP, postMessage, and CORS
- MCP App capability detection - feature gates and fallbacks
- Security testing MCP Apps - CSP, auth, annotations, and input validation
- Cross-host testing MCP Apps - ChatGPT, Claude, themes, and display modes
- MCP App framework
- MCP App inspector
- sunpeak docs - MCP App resource _meta
- MCP Apps permissions type reference
- Chrome docs - Permissions Policy
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.