Skip to content

Scope MCP OAuth tokens to user + workspace (#222) - #245

Merged
paulocastellano merged 27 commits into
mainfrom
feature/mcp-oauth-user-workspace-scope
Aug 7, 2026
Merged

Scope MCP OAuth tokens to user + workspace (#222)#245
paulocastellano merged 27 commits into
mainfrom
feature/mcp-oauth-user-workspace-scope

Conversation

@paulocastellano

@paulocastellano paulocastellano commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Closes #222.

Supersedes draft #223 (reimplemented on current main after #241 MCP settings landed).

Summary

MCP OAuth tokens (Claude/ChatGPT connectors) are now scoped to user + workspace, matching personal API keys. Requests resolve the workspace from the token instead of the user's current_workspace_id, so a multi-workspace agent cannot silently act on the wrong tenant.

How binding works

Step Mechanism
Consent UI Inertia mcp/Authorize with workspace Combobox; App\Passport\AuthorizationView
Auth code AuthCodeRepository stores chosen workspace_id (form) or current workspace (silent re-consent), with membership check
Token issue AccessTokenRepository copies workspace from the auth code / inherits on refresh — no current-workspace fallback on authorization_code
Runtime LoadWorkspaceFromToken reads only token.workspace_id + can('view')
Backfill One-shot migration for live MCP grants; ambiguous multi-workspace → revoke
Member removal Revokes that user's MCP + PAT tokens for the removed workspace

Also included

  • Shared RevokeAccessTokens (access + refresh)
  • API key list/delete filtered with personalAccessApiKey() so MCP grants cannot be managed as keys
  • MCP settings list/disconnect scoped to the current workspace

Automated tests

  • Auth-code binding (consent workspace wins over later switch)
  • Auth-code with null workspace fails closed (no current-workspace fallback)
  • Refresh inherits refreshed token workspace amid sibling grants
  • Silent re-consent / form workspace picker / reject non-member workspace
  • Unbindable grant → invalid_grant before token save
  • Middleware: bound / null / non-member / switcher ignored
  • Backfill bind / sole membership / revoke ambiguous / ignore PATs & dead grants
  • RevokeAccessTokens, RemoveMember, API keys vs MCP, MCP settings scope
  • AuthorizationView props + OAuth consent Inertia page

Manual QA (visual / end-to-end)

Run with npm run dev (or build) so the new Inertia authorize page loads.

Consent screen UI

  • Open MCP connect from Settings → MCP (or MCP Inspector) and confirm the authorize popup/page shows the TryPost logo, heading, logged-in email, workspace Combobox, permissions, Authorize + Cancel
  • Confirm layout looks clean (no shield icon, no duplicated logo, Combobox spacing OK on desktop and a narrow popup width)
  • Switch locale (e.g. pt-BR) and confirm consent copy is translated

Workspace picker

  • User with one workspace: Combobox shows that workspace pre-selected; authorize succeeds
  • User with two+ workspaces: search/filter works; pick a workspace that is not the current one; authorize
  • After connecting, confirm Settings → MCP “Connected apps” lists the client on the workspace you picked (switch workspace in the app and confirm it does not appear on the other one)

Token scoping behavior

  • With an MCP client connected to workspace A, switch the UI to workspace B and use the agent — it should still only act on A (posts/accounts from A)
  • Connect the same client again choosing workspace B — both connections can coexist; each settings page shows only its own
  • Disconnect from Settings → MCP on one workspace; the other workspace’s connection stays

Cancel / errors

  • Click Cancel on the consent screen — client does not stay connected; popup closes (or returns with error) cleanly
  • Viewer role can still open consent and connect (no create-post gate on authorize)

Regression smoke

  • API keys page still lists/creates/deletes personal keys only (no MCP OAuth clients mixed in)
  • Existing personal API key still works against the API for its bound workspace

Bind authorization-code grants to the authorizing workspace (via auth codes),
inherit workspace on refresh, resolve MCP/API requests from the token instead
of current_workspace_id, backfill existing grants, and revoke workspace tokens
when a member is removed.

Co-authored-by: Cursor <cursoragent@cursor.com>
paulocastellano and others added 26 commits August 6, 2026 12:02
Cover coexistence of the same client across workspaces, settings
list/disconnect scoped to the current workspace, and API key
controllers excluding workspace-bound MCP grants.

Co-authored-by: Cursor <cursoragent@cursor.com>
Match the project's UUID foreign-key convention instead of a separate
foreign() call.

Co-authored-by: Cursor <cursoragent@cursor.com>
Wire authorize.blade.php to mcp.* translation keys (including the
workspace scope copy) and cover pt-BR rendering.

Co-authored-by: Cursor <cursoragent@cursor.com>
CI treats the non-compound `use Mockery` as an ErrorException and
aborts the whole parallel suite.

Co-authored-by: Cursor <cursoragent@cursor.com>
Move the one-shot backfill out of a dedicated Action and wrap it in an
explicit transaction so a failure rolls back partial binds/revokes.

Co-authored-by: Cursor <cursoragent@cursor.com>
Group consent-screen copy under mcp.authorize.*, and assert the
workspace backfill migration rolls back binds when it fails before
commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
Drop the config('app.name') interpolation from the consent screen title.

Co-authored-by: Cursor <cursoragent@cursor.com>
Let users choose which workspace to bind at authorize time instead of
always using current_workspace_id; silent re-consent still falls back.

Co-authored-by: Cursor <cursoragent@cursor.com>
Match NativeSelect styling and give the label, control, and helper text room to breathe.

Co-authored-by: Cursor <cursoragent@cursor.com>
Reuse AuthCardLayout, Button, and NativeSelect so the authorize page
matches the app UI. Keep native form posts so Passport's external
redirect still works for MCP client popups.

Co-authored-by: Cursor <cursoragent@cursor.com>
Drop the shield and AuthCardLayout double-logo, put TryPost branding
at the top, and reuse the app Combobox pattern for workspace search.

Co-authored-by: Cursor <cursoragent@cursor.com>
Reuse AccessToken::mcpOAuth() so the migration only touches mcp:use
grants on non-PAT clients, matching the rest of the codebase.

Co-authored-by: Cursor <cursoragent@cursor.com>
Only touch connected MCP sessions, bind a sole membership or a valid
current workspace, and revoke ambiguous multi-workspace grants instead
of guessing the oldest workspace.

Co-authored-by: Cursor <cursoragent@cursor.com>
Always use the app default database connection from .env.

Co-authored-by: Cursor <cursoragent@cursor.com>
Replace the AccessTokenCreated listener with the same Passport repository
override pattern used for auth codes, so workspace_id is set at persist.

Co-authored-by: Cursor <cursoragent@cursor.com>
Drop redundant string casts and the oldest-workspace fallback; keep a
small ownedWorkspace/payloadId helper surface instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
Keep configurePassport thin by moving the Inertia consent props into an
invokable App\Passport\AuthorizationView class.

Co-authored-by: Cursor <cursoragent@cursor.com>
Use collection higher-order mapping for workspaces/scopes and add focused
tests for current-workspace selection and empty-user props.

Co-authored-by: Cursor <cursoragent@cursor.com>
The suite now covers AuthCodeRepository and AccessTokenRepository
workspace binding, not an AccessTokenCreated listener.
Authorization-code grants no longer fall back to the user's current
workspace, so a token cannot be minted for a different tenant than consent.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Disable Passport silent re-consent and require an explicit workspace_id
from the consent form, with Passport wiring moved to its own provider.

Co-authored-by: Cursor <cursoragent@cursor.com>
Show most recently used OAuth connections first on the workspace MCP settings page.

Co-authored-by: Cursor <cursoragent@cursor.com>
@paulocastellano
paulocastellano merged commit 2ca5948 into main Aug 7, 2026
5 checks passed
@paulocastellano
paulocastellano deleted the feature/mcp-oauth-user-workspace-scope branch August 7, 2026 00:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scope MCP OAuth tokens to user + workspace (with backfill)

1 participant