Every agent that connects to Relay passes through one screen, and that screen currently asks you to authorize “this application” — without naming it. Not because the name is unavailable: we store it at registration and never read it back. The first three states below are the same renderer, the same data, one argument apart.
The fourth state is the reason naming it is harder than it looks, and the fifth is the ask EC flagged — an optional, unchecked data-capture box, drawn both above and below the action so the placement can be picked rather than assumed.
Revised 2026-09-30. Four more states from the same renderer: a local app (VS Code, Cursor, Claude Code) whose redirect is 127.0.0.1 — a host that reads like a fact and identifies nothing — before and after it can be named; a web connector named the same way; and the workspace picker that shows only when a workspace requires connections authorized for it by name. §5 is where the name lands.
“MCP Connector needs to have some implied consent about tracking etc available if we want.”
EC · 2026-09-13 · on the Klaviyo MCP Server authorization screenSwitch between them. Nothing is re-mocked per state — each is the same function given a different client, which is how the server does it. Try Authorize in the Stage 2 state without ticking the box: it authorizes, because an optional ask that blocks is not optional.
Three of these were already in the process when the page rendered. The screen simply did not print them.
| Fact | Where it already was | Today’s screen | Stage 1 |
|---|---|---|---|
| Which client | oauth_clients.client_name, written at registration, read back by oauth_client_get() | “this application” | Named, as a claim |
| Where the grant lands | redirect_uri, bound into the grant by PKCE | Not shown | Host, in mono |
| What it covers | One scope, relay — there is no narrower one | “access your Relay account” | Stated: the whole account |
| Optional data capture | Nothing to show — Relay collects none of this today | — | — (Stage 2 proposal) |
The argument that was missing. handle_oauth_authorize() read client_id, wrote it to the state row, and then called the consent page without it. The fix is one argument at one call site — which is why this is Stage 1 and not a project.
These two specimens look alike on purpose — that resemblance is the hazard. Click Continue on each with the box unchecked.
.consent-rowAn ask riding alongside a required action. Unchecked is declined, and declining costs nothing.
.consent-row--requiredConsent the form needs. Gates submit, flags on an intercepted attempt, takes focus. Governed by GUIDE.forms.md §6.
Why one component, not two. Split into separate lookalikes they drift, and the drift runs one way: the optional one quietly acquires the gate. One component with a modifier makes that drift a deliberate edit instead of an accident. Contract: GUIDE.forms.md piece 6b.
Neither ever ships pre-checked. A pre-checked box records nothing about the person; it records that we shipped a checkbox.
Relay registers OAuth clients dynamically and verifies nothing: handle_oauth_register() accepts whatever client_name it is sent, and the client_id is a hash of the redirect URI. Anyone can register a client called Claude. A screen that prints that name as a bare fact hands an attacker a Relay-branded page vouching for them.
So the name is rendered as a claim — “an application identifying itself as…” — and the sentence continues to the host, which is the fact. PKCE plus exact-match redirect_uri mean a client that lies about its name still cannot lie about where the code lands. Switch state 01 to Spoofed name and read the two together: the name is perfect, and the screen still tells the truth.
The cheap upgrade this enables. A small allow-list of known-good (name, host) pairs — claude.ai, cursor.sh, vscode.dev — lets the screen mark a client recognized and leave every other one plainly unmarked, without blocking anything. Worth doing on its own merits.
Nothing in the OAuth exchange carries the machine. The browser will not reveal it, and a local client’s redirect is 127.0.0.1 on every computer on earth — two laptops running VS Code authorize to the identical address, and any app on either could register itself as “Visual Studio Code”. What the server can see is listed below. The only thing that tells “work laptop” from “home desktop” is a name the person types, so the screen asks for one — prefilled with the best guess, never required.
| Signal | Local app (VS Code, Cursor) | Web connector (claude.ai) | Used for |
|---|---|---|---|
| Hostname | Not available — no field carries it | Not applicable | — |
| Client’s own name | “Visual Studio Code” — a claim | “Claude” — a claim | The prefill |
| Redirect host | 127.0.0.1 — proves only “this computer” | claude.ai — the fact | The chip on the client card |
| Browser at consent | User agent (“Chrome on macOS”) and network address → approximate place | “Authorized from…”, the prefill’s OS | |
| Address of each call | The machine itself → “last used from…” | claude.ai’s servers — says nothing about you | “Last used” line, local only |
Type in the Name this connection field in state 01 (Local app — named): the first row below is that name. Today every row here reads “Mcp Client — email”, so two VS Code connections are indistinguishable.
Revoke is drawn, not real. Revoking an MCP connection does not reach the MCP path today (RCTX-1368) — this list must not ship with that button until it does.
Places are approximate and only ever yours. Derived from the network address to a city at most, never shown to a workspace admin. Whether an admin’s view of grants into their workspace (RCTX-1429) shows any of this is a separate call.
Two picks for EC. Prefilled or blank-and-required? Prefilled means most people never touch it, and many rows read “VS Code · Mac”; required costs a keystroke at every connect. Drawn prefilled, with Rename as the backstop. And should the agent be allowed to report its own hostname through relay_context? It can read it — but self-reported, so a hint, never proof.
Checked at the call, not the comment: the MCP surface logs one row per tool call carrying tool name, account and connection ids, client platform, duration and outcome. The arguments parameter is in scope on that line and is not logged.
| Tier | What it is | Does Relay have it | Needs an ask? |
|---|---|---|---|
| T1 · Operational | Which tool, when, by whom, how long, did it work — plus the abuse counters built on it | Yes, today | No — but it is currently undisclosed, which is its own fix |
| T2 · Content-derived | A characterization of what you were doing, derived from your prompt. “Prompt intent” is exactly this | No — nothing derives or stores it | Yes. Opt-in, unchecked, revocable, never a condition of connecting |
| T3 · Content | The prompt, the arguments, the relay body | Only as your material, for you | Already yours, under your own residency region |
“Anonymized” describes the output, not the input. To summarize intent, something must read the prompt. The consent is for the reading, not just the keeping — which is why the detail line has to name what is not collected, or it is not informed.
And the prompt may not be the account holder’s. On a claim, the person at the keyboard can be a delegate working in someone else’s session. An account-level opt-in cannot speak for a third party’s text. Relay has a reason to be more conservative here than a single-tenant tool needs to be.
| Piece | Status | Where |
|---|---|---|
| Client named + host + scope line | Built — branch, not merged | relay-platform routes/oauth.py |
| The consent control | Built — component only, nothing consumes it | relay-app @relay/ui — .check, .consent-row |
| Piece 6b — the optional-ask law | Landed | guidelines/GUIDE.forms.md |
| T1 telemetry disclosed in the privacy surface | Open — the one item with a real deadline | relay-platform + legal copy |
| Workspace picker + per-call gate | Built — branch, not merged (RCTX-1426) | relay-platform connection_grants.py, routes/oauth.py |
| Local-app wording, connection name, “authorized from” | Proposed — this page, rev 2026-09-30 | Would ride routes/oauth.py + the connections row |
| The ask itself (T2 capture) | Proposed only. Nothing collects; the switch would ship before the thing it switches, never after | Gated on the pick below |
The pick, stated honestly both ways. For: “which tool, in which sequence, before the failure” would sharpen the surface faster than anything else on offer. Against: Relay’s pitch is custody of context, and a box asking to characterize prompts sits awkwardly beside it — even unchecked, even declined, the ask itself is a statement about what we think we are entitled to.
Session recommendation: not yet. Take Stage 1, which costs nothing and fixes a real defect. Leave the box drawn and unbuilt until there is a question we actually need it to answer. Full reasoning: relay-platform docs/DESIGN-NOTE.mcp-authorization-consent.md.