Plane docs
Guides

Per-user OAuth

A tool that acts as the person the session is for.

Some MCP servers should act as the person, not as your company. Register the connection with auth: { type: "oauth" } and Plane holds a grant per (connection, identity, resource).

await client.connections.upsert({
  name: "linear",
  url: "https://mcp.linear.app/mcp",
  auth: { type: "oauth", scopes: ["read", "issues:create"], signed: true }
})

The connection is the consent boundary

A grant is keyed (connection, identity, resource)no agent id, no session id. That absence is what "authorize once, and every agent that uses this connection can act" means. Two agents that need different scopes from one server need two connections.

When a tool has nowhere to go

The call comes back as an authorization-required result, the model tells the user, and a connection.authorization_required event lands on the session. The link that fixes it is minted on demand, for an authenticated caller — an authorize URL is a capability, so it is never in the event stream.

for (const row of await plane.authorizations.pending(session.id)) {
  // row.connection, row.scopes, row.url, row.expiresAt
  await slack.postMessage(`Connect ${row.connection}: ${row.url}`)
}

Or drive the flow directly, which is what a "Connections" screen in your own app does:

const { url, expiresAt } = await client.connections.authorize({
  connectionId,
  identity: { scope: "user", id: "u_412" },
  // Your own route knows which of your users is looking at the screen; Plane does
  // not. Must be on the deployment's redirect allow-list, matched exactly.
  redirectUri: "https://app.example.com/oauth/done",
  sessionId: session.id,
  scopes: ["issues:create"]        // a step-up; unioned with what the grant holds
})

Redirect URIs are an exact-match allow-list. No prefixes, no wildcards: open redirectors are how authorization codes get stolen.

Managing grants

await client.connections.grants.list({ identity: { scope: "user", id: "u_412" } })
await client.connections.grants.revoke({ id: grantId })

A grant read never contains a token. The shape has no field for one.

Migrating from a system you already run

await client.connections.grants.import({
  connectionId,
  mode: "skip_existing",           // never downgrades somebody who re-authorized in Plane
  grants: [
    {
      identity: { scope: "user", id: "u_412" },
      accessToken: "…",
      refreshToken: "…",
      externalId: "legacy:8814"    // provenance, and the idempotency witness
    }
  ]
})

Idempotent on (connectionId, identity, resource), at most 200 rows per call.

What Plane does for you

  • Scope precedence: connections.authorize({ scopes }), then the server's WWW-Authenticate challenge, then the connection's own auth.scopes, then the resource server's scopes_supported — unioned with whatever the grant already holds, so an upgrade never narrows. Your configured scopes rank above scopes_supported on purpose: an operator who configured read should not be sent to a consent screen asking for everything the server publishes.
  • Refresh is single-flight per grant. The four parallel tool calls of one step produce one refresh rather than four, which on a server that rotates refresh tokens is the difference between one valid token and three invalidated ones.
  • Discovery, PKCE and dynamic client registration are handled. resource is sent always, per the spec.
  • A stale grant (invalid_grant) routes back into the "authorize" path rather than a new error every caller has to learn.

On this page