Pro

Authorize

The consent screen an MCP server shows when an agent asks to connect: who's asking, what it will be able to do, and where you go back to. Risky permissions start off.

Why it works this way: What an MCP consent screen needs.

Copper connecting to Acme’s MCP server

Copper wants to access Acme

  • See your bookings (always included)
  • See menus and recipes (always included)

You’ll return to localhost:33418

The first Allow fails on purpose, so you can see the error and retry.

An app the server doesn’t know, read-only

pantry-sync wants to access Acme

  • See your bookings (always included)
  • See menus and recipes (always included)

Acme hasn’t verified pantry-sync. Only allow it if you started this yourself.

You’ll return to pantry-sync.example.net

Installation

With UI Polish connected, ask your agent:

Add the UI Polish authorize to this project.

Or with the shadcn CLI:

npx shadcn@latest add https://uipolish.com/r/authorize.json
Package@base-ui/react
Brings inAnimated size, Button, Checkbox, Icons, Select, Text

The page your MCP server shows when an agent asks to connect: the OAuth consent step between "Connect" in the agent and a working connection. One small centered card: the two apps' marks joined by a dotted line, a heading that says who wants what, the account it will act as, the permissions as a list between two hairlines, a full-width Allow with Cancel under it, and where the browser goes back to. Nothing sits on the line until there's a result: connecting draws it in solid with a small check on it; a failure leaves it dotted with a small x.

The heading, account and buttons are centered; the list keeps a straight left edge, because centered checkboxes don't line up.

When to use it

  • The authorization endpoint of an MCP server, or any OAuth provider that agents connect to.
  • Any "App X wants access to your account" step where the person should read what they're granting before they agree.

When not to

  • Approving one action the agent is about to take ("Send this email?"). Use tool approval: that's one call, not a standing grant.
  • Signing in. Show your sign-in first, then this, once you know who they are.
  • Settings for apps already connected. List those with what they can do and a Disconnect button.

States

status is controlled: your page owns the OAuth request.

StatusMarksBodyButtons
reviewA plain dotted lineTitle, account, permissions, warning if unverified, return hostAllow, Cancel
submittingA soft band passes along the dots toward the serverSame, permissions inert; once the button shows busy (300ms) they dim with itAllow busy: "Connecting…" (a retry keeps "Try again" until then), Cancel disabled
errorThe dots step back, a small danger disc with an x in the middleSame, error under Cancel, so the buttons don't move"Try again", Cancel
connectedThe line draws in solid, a small ink disc with a check in the middle"Connected to Acme", go back to the clientnone
deniedThe dots step back, nothing on the line"Not connected", nothing was sharednone

Marks. Each side is a 48px tile with a 14px corner, a hairline edge and the raised shadow. Pass a logo as mark (an <svg> or <img>, drawn at 28px). Without one, the first letter of the name stands in on a soft tint: the accent turned to a hue picked from the name, so the same app always gets the same color, at the accent's own weight in light and dark.

Permissions. One list under "It will be able to". Write each as a verb phrase a person would say: "See your bookings", "Change shopping lists", "Issue refunds", not bookings:read. The verb does the grouping, so there are no headings. Required scopes show the same box as the checkboxes, filled grey with a check and no hover, so they read as on and fixed; optional ones are checkboxes, ticked unless defaultGranted: false. Leave anything that moves money or deletes things optional and off by default. Add a description only when the phrase isn't enough.

The button says what it does. Untick every access: "write" scope and Allow becomes "Allow read-only".

Unverified clients. MCP clients usually register themselves, so most agents are ones your server has never seen. Unless you set verified, one line in the warning color tells the person to allow it only if they started this themselves. That and the return host (returnTo, the redirect URI's host and port, shown exactly, localhost and port included) are what stop a crafted link.

Account. Pass account={{ email }}. With onSwitchAccount, the account is the compact select: the email, any otherAccounts signed in on this browser, and "Use another account". Picking an email calls onSwitchAccount(email); "Use another account" calls it with nothing, so you can send the person to sign in. Without the callback, the email is plain text.

Motion

One quiet change per state, on the glide and enter/exit curves; nothing grows, bounces or swaps glyphs in place.

WhatDurationEasing
Submitting: a soft band of the muted tone passes along the dots toward the server, the same window and pace as the button's busy sweep--ui-dur-sweep (2400ms) a pass; the band fades in over --ui-dur-baselinear; fade --ui-ease-color
Connected: the solid line draws from the client to the server while the dots fade--ui-dur-glide (320ms)--ui-ease-glide
Connected: the check disc fades in once the line reaches the middle--ui-dur-base, after --ui-dur-glide / 4--ui-ease-enter
Failed: the dots step back to half, the x disc fades indots --ui-dur-instant; disc --ui-dur-fast--ui-ease-color; --ui-ease-enter
A result mark leaving (retry after a failure)--ui-dur-instant--ui-ease-exit
Error line: its room opens and the return line glides down with it; the text fades inopen --ui-dur-glide, close --ui-dur-base; text --ui-dur-base--ui-ease-glide; --ui-ease-enter
Result replaces the form: it fades in at once while the card glides to its new height--ui-dur-base; the card --ui-dur-glide--ui-ease-enter; the card --ui-ease-glide
Allow label: "Allow" ↔ "Allow read-only", "Allow"/"Try again" → "Connecting…"the button's text morph

Inside the form the card follows its content frame by frame (only the error slot animates its own height), so nothing chases or clips.

Under prefers-reduced-motion: reduce there's no band (the dots simply take the muted tone while submitting), the solid line fades in whole instead of drawing, the check appears without a delay, and the result and error appear without fading.

Accessibility

  • A <section> named by its heading. Use headingLevel={1} (the default) when it's the whole page, as it usually is.
  • The marks are decorative; the heading names both sides.
  • The permissions are a list named "It will be able to", so a screen reader hears "It will be able to, list, 5 items". The heading already says it, so the name isn't shown.
  • "Connecting Copper to Acme" is announced while submitting, the error uses role="alert", and on connected or cancelled focus moves to the result heading, since the buttons it came from are gone.
  • The account select is named "Account"; on touch screens it opens the native picker.
  • Required permissions say "(always included)" to screen readers.

Before / after

Before: the scope names straight from the OAuth request, no idea who's asking or where you'll be sent, and one button.

TSX
<h1>Authorize application</h1><p>{clientId} is requesting: {scope.split(" ").join(", ")}</p><button onClick={approve}>Authorize</button>

After: who's asking, as whom, what it can do in plain words with the risky ones off, and where the browser goes next.

TSX
<Authorize  client={{ name: registration.client_name, returnTo: new URL(redirectUri).host, verified: isFirstParty }}  server={{ name: "Acme", mark: <AcmeLogo /> }}  account={{ email: user.email }}  onSwitchAccount={() => signOutAndReturn(request)}  scopes={describeScopes(request.scope)}  status={status}  onAllow={async (granted) => {    setStatus("submitting");    try {      redirectTo(await issueCode(request, granted));      setStatus("connected");    } catch {      setStatus("error");    }  }}  onDeny={() => {    redirectTo(denied(request));    setStatus("denied");  }}/>