Account Pairing¶
A document created over the unauthenticated MCP endpoint has no owner. Ownership is established afterward by a claim handshake that binds the draft to a user account.
In brief. An anonymous create_document writes an unclaimed draft and hands the model a claim token (S) and a short claim link (R). The user opens /claim/<R>, which resolves R to the document GUID H, and signs in; signing in claims the draft and lands them on it. S survives the claim as the key for update_document edits.
A deployment that wants the claim tied to the party the model is actually talking to turns on device-code claiming, which inserts a second step: the claim page mints a six-digit device code (C) for the signed-in account, the user reads it back into chat, and the model replays claim_document(S, C). That handshake resists an attacker who never saw the chat — chiefly prompt injection — at the cost of a transcription step. It is off by default, and claim_document is the tell: the tool is registered only in that mode, so a deployment whose tool list omits it claims on sign-in alone.
The elements¶
S, R, and C are stored only as hashes, never in plaintext; H is the raw row id.
S— claim token. Model-carried; no human transcribes it. Resolves the row atupdate_document, and atclaim_documentwhere that step exists, so it lives for the draft's lifetime — ending only when the draft expires and the row is swept. (Save Copy promotes a copy into the user's library; it does not delete the draft or retireS.)R— claim link. A short code that travels in clear chat text — a chat-safe handle for the draft, not a separate authority. Its page,/claim/<R>, is where claiming happens: it gates sign-in and then either claims the draft outright or mints the device codeC, and serves no document content pre-claim. Only once the draft is claimed does the page redirect the now-owner to the viewer at/ephemeral/<H>. Durable: it keeps resolving until expiry, so a re-click always lands.H— document GUID. The 128-bit row id. Pre-claim it serves no content (claiming lives on/claim/<R>); post-claim it is the stable, owner-gated document address (/ephemeral/<H>).C— device code. Minted only under device-code claiming. Six digits, single-use, rate-limited — the only human-transcribed element. Salted byHand verifiable only against that row. Bound to the authenticated account that minted it (pending_owner_id); a different account viewing it trips tamper.
Flow¶
create_document(content)→ row writtenowner_id = NULLwithS,R, and a 5-minute deadline. ReturnsSand the/claim/<R>link to the model; records the delivery mode.- The model asks the user to open the link and sign in.
/claim/<R>resolvesR→H, gates sign-in, setsowner_idto the authenticated account, and bumpsexpires_atto +24 h. The user is redirected to/ephemeral/<H>, which renders the draft with a Save Copy action that promotes it into their documents.
sequenceDiagram
participant Mdl as Model
participant Server
participant Usr as User
Mdl->>Server: create_document(content)
Server->>Server: write draft, owner_id = NULL (5-min deadline)
Server-->>Mdl: claim token S, claim link R
Mdl->>Usr: "open /claim/R and sign in"
Usr->>Server: open /claim/R, sign in
Server->>Server: owner_id = user, expires_at +24h
Server-->>Usr: redirect to /ephemeral/H
Note over Usr,Server: Save Copy promotes draft to documents
With device-code claiming¶
Steps 1 and 2 stand, except the model also asks for the code, and step 3 splits:
/claim/<R>resolvesR→H, gates sign-in, mintsC, binds it to the authenticated account (pending_owner_id), and extends the row into a 10-minute claim window.- The user pastes
Cinto chat; the model callsclaim_document(S, C). - The server verifies
SandC, promotespending_owner_id → owner_id, and bumpsexpires_atto +24 h.Cis consumed;SandRstay live.
Claiming then takes both halves — the link and the model's token — held by parties that reached the draft by different routes.
Post-claim updates (update_document)¶
update_document(S, content) resolves the claimed row by S and overwrites its content in place — no second handshake, link, or device code — and bumps expires_at back to the full claimed window. An owner_id IS NOT NULL guard keeps it strictly post-claim; the row (and S) live until the draft's expires_at, when a sweep removes them — Save Copy promotes a copy into the user's library but leaves the draft live, so update_document keeps editing the draft (not the saved copy) until it expires. Possession of S is the write capability: the call does not re-check the session. It is write-only — it cannot read the document or re-own it — and does not widen the injection surface, since an attacker able to read S from model context can already call it.
Principal model¶
Sign-in is the claim gate; the user account (owner_id) is the durable principal. Under device-code claiming, C binds to the authenticated account (pending_owner_id) during the mint→claim window: only the account that minted C is shown it (others get a tamper page), so only that user completes the claim — from any browser. Post-claim, forward access is gated by owner_id, consistent with the rest of the app's auth.
Delivery mode and trust¶
How the claim link reaches the user sets the injection boundary, and the server decides it (never the model):
- Out-of-band — a host UI surface the model cannot read: injection captures
Sbut neverR/H, so it can neither open the claim page nor mintC. Full trust, in either claim mode. - In-band — the link rides in model text: injection sees
R, which by default is the whole capability. Reduced trust.
The MCP endpoint is stateless Streamable HTTP with no host surface that can show the claim link out of band, so every MCP-created draft is recorded in_band — the reduced-trust path. Out-of-band delivery requires a host UI that displays the claim link where the model can't read it.
The two settings compose as a matrix over what an injection that reads the whole model context can do:
| Delivery | Default claim | Device-code claim |
|---|---|---|
Out-of-band (R unseen) |
Injection holds S: write-only, cannot claim |
Same, plus it cannot mint C |
In-band (R in chat) |
Injection holds R: claims and reads the draft |
Injection holds S and R: equivalent to handing over the claim |
The bottom-right cell is why device-code claiming is not a fix for in-band delivery on its own — it raises the cost of a forwarded or overheard link, not of an attacker reading the same context the model does.
Trust is derived from the recorded delivery_mode at decision time, not stored as a separate flag; delivery_mode lives on the ephemeral row only and is not carried onto the document on promotion.
Known limitation: first-visitor-wins¶
Both modes are first-visitor-wins, and what that costs differs.
By default the first authenticated visitor owns the draft: an attacker who holds R and any account takes it, and the legitimate user lands on "already claimed". This is a disclosure, and it is the accepted trade of the default posture — the draft is content the user just asked a model to write, and the link is short-lived.
Under device-code claiming the first visitor only stages a claim (pending_owner_id), so winning that race without S yields nothing but grief: the legitimate user trips the tamper page and the draft expires unclaimed. That is an availability grief, not a disclosure — confidentiality rests on S, which is model-carried and never enters the URL, logs, or sign-in callback.