Context
Today MCP OAuth tokens (Claude/ChatGPT connectors) are scoped to the user only — oauth_access_tokens.workspace_id is null — while API keys (personal access tokens) are scoped to user + workspace (ApiKeyController sets workspace_id at creation).
Because the token carries no workspace, the MCP server resolves the workspace at request time from the user's current_workspace_id (see app/Mcp/Tools/** and LoadWorkspaceFromToken). That has a few consequences:
- A teammate's MCP connection silently operates on whatever their current workspace happens to be — in multi-workspace accounts, an agent can act on the wrong workspace without anyone noticing.
- The onboarding MCP step is account-scoped (
ResolveOnboardingStatus::accountHasMcpConnection) precisely because tokens have no workspace to check.
- The MCP settings page lists connections account-wide, and the account owner cannot revoke a teammate's grant (
McpSettingsController::disconnect filters by the requester's user_id).
- Offboarding relies on membership checks alone: a removed member's token stays valid and silently works again if they are re-added.
Proposal
Bind MCP OAuth tokens to a workspace, mirroring API keys:
- Connect flow: the OAuth authorize URL captures the workspace being connected (state parameter), and the issued token gets
workspace_id set.
- Request resolution:
LoadWorkspaceFromToken / MCP tools resolve the workspace from the token's workspace_id instead of the user's current_workspace_id (PATs already work this way).
- Backfill: data migration assigning existing MCP OAuth tokens to the user's current workspace (or first workspace on the account); tokens that can't be mapped confidently get revoked with a reconnect path.
- Settings page: list connections per workspace instead of account-wide; revisit whether the account owner may revoke a teammate's grant.
- Onboarding:
accountHasMcpConnection can become workspace-scoped again (or stay account-scoped deliberately — decide).
- Tests: token creation binding, request resolution from token workspace, backfill mapping/edge cases, disconnect semantics.
Out of scope here
Deliberately not part of #204 (post-subscription onboarding) — the model change + backfill deserves its own PR.
References
app/Models/AccessToken.php (activeMcpOAuth, isMcpOAuthGrant)
app/Http/Controllers/App/McpSettingsController.php
app/Http/Middleware/Api/LoadWorkspaceFromToken.php
app/Actions/Onboarding/ResolveOnboardingStatus.php
app/Http/Controllers/App/ApiKeyController.php (PAT user+workspace model to mirror)
Context
Today MCP OAuth tokens (Claude/ChatGPT connectors) are scoped to the user only —
oauth_access_tokens.workspace_idisnull— while API keys (personal access tokens) are scoped to user + workspace (ApiKeyControllersetsworkspace_idat creation).Because the token carries no workspace, the MCP server resolves the workspace at request time from the user's
current_workspace_id(seeapp/Mcp/Tools/**andLoadWorkspaceFromToken). That has a few consequences:ResolveOnboardingStatus::accountHasMcpConnection) precisely because tokens have no workspace to check.McpSettingsController::disconnectfilters by the requester'suser_id).Proposal
Bind MCP OAuth tokens to a workspace, mirroring API keys:
workspace_idset.LoadWorkspaceFromToken/ MCP tools resolve the workspace from the token'sworkspace_idinstead of the user'scurrent_workspace_id(PATs already work this way).accountHasMcpConnectioncan become workspace-scoped again (or stay account-scoped deliberately — decide).Out of scope here
Deliberately not part of #204 (post-subscription onboarding) — the model change + backfill deserves its own PR.
References
app/Models/AccessToken.php(activeMcpOAuth,isMcpOAuthGrant)app/Http/Controllers/App/McpSettingsController.phpapp/Http/Middleware/Api/LoadWorkspaceFromToken.phpapp/Actions/Onboarding/ResolveOnboardingStatus.phpapp/Http/Controllers/App/ApiKeyController.php(PAT user+workspace model to mirror)