SCAL-319970: Multi-org support (list_orgs / switch_org) on shared mcp-auth - #173
SCAL-319970: Multi-org support (list_orgs / switch_org) on shared mcp-auth#173rohitthughtspot wants to merge 11 commits into
Conversation
Applies the multi-org feature on top of merged main (thoughtspot#165 auth extraction), reconciled with intervening main work: - 3-way merged types.ts and tool-definitions.ts so thoughtspot#171's answer_data_source_id coexists with the org types/tools (a blind copy of the pre-thoughtspot#171 branch had reverted it, breaking streaming-utils.ts). - Auth-adjacent bits ride the @thoughtspot/mcp-auth hooks in index.ts: authMode (enrichMcpRequestProps -> oauth, extendProps -> bearer/token), extendGrantProps carrying refreshToken/expiry into the grant for keep-warm, UserTokenStore export. - All org logic (list_orgs/switch_org, OrgService, UserTokenStore DO, keep-warm, isolation/fan-out/F2a/F4/T3 + tests) unchanged app-side. Requires @thoughtspot/mcp-auth with the extendGrantProps hook. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…til re-auth) A grant minted before multi-org shipped has no refresh token in its props (extendGrantProps adds it at login). areOrgToolsAvailable() now also requires hasMultiOrgGrant() (refreshToken present), so such sessions: - do NOT see list_orgs/switch_org, and - get NO org overlay/minting — they keep the pre-multi-org behavior (login token, no forced re-mint) until the user re-authenticates. This fixes the observed break where an old grant hit "authentication expired" because the org overlay tried to mint against a stale global token. Tests cover both: old grant hides org tools and applies no overlay. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ken presence) Move the pre/post-multi-org grant check to a single init-time decision: postInit sets grantHasRefreshToken = (props.refreshToken present), and hasMultiOrgGrant() reads that flag. init() awaits postInit before any tool call or listTools, so the flag is always set first (no race). Everything multi-org keys off this one decision: tool visibility, the connect overlay, and the per-request org load. A pre-multi-org grant (no refresh token) keeps the old no-org behavior until re-auth. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…session-aware) Locks the backward-compat contract: org selection is shared among re-authenticated sessions (per-user via hash(refreshToken)), but a still-legacy grant (no refresh token) must keep working as-is until re-auth. Even sharing the same store, an old grant resolves no active org and keeps using its login token after a new session's switch_org. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ests - Depend on @thoughtspot/mcp-auth ^2.0.0 (adds the extendGrantProps hook that carries the refresh token into the OAuth grant for keep-warm). - Tests: listOrgs field-mapping edges (id/name fallback, id-as-name, empty description -> undefined) and switch_org non-4xx (5xx) -> generic retry error, not "no access", and a no-op. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces multi-org support and token keep-warm capabilities to the ThoughtSpot MCP server, adding a new UserTokenStoreSQLite Durable Object to manage active org states and keep-warm tokens, alongside new list_orgs and switch_org tools. The code review highlights three key areas for improvement: addressing a potential race condition in refreshTokenStore by using a transaction, avoiding redundant writes in active-org updates, and fixing a fallback bug in loadOrSeedWarmToken where an expired stored token could be incorrectly preferred over a fresh context token.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| await this.state.storage.put<TokenStore>(TOKEN_STORE_KEY, { | ||
| accessToken, | ||
| refreshToken, | ||
| instanceUrl: store.instanceUrl, | ||
| // Keep the prior expiry if the response omits one. | ||
| expiresAt: | ||
| typeof newExpiresAt === "number" ? newExpiresAt : store.expiresAt, | ||
| lastSeenAt: store.lastSeenAt, | ||
| }); |
There was a problem hiding this comment.
There is a potential race condition where a user re-authenticates and re-seeds the token store (via POST /token-store) while an alarm-triggered refreshTokenStore is awaiting the fetch call. When the fetch completes, refreshTokenStore could overwrite the newly seeded token store with the old refreshed credentials. To prevent this, we should perform the read and write operations within a transaction to ensure atomicity, and abort the write if the refresh token has changed.
await this.state.storage.transaction(async (tx) => {
const currentStore = await tx.get<TokenStore>(TOKEN_STORE_KEY);
if (!currentStore || currentStore.refreshToken !== store.refreshToken) {
console.log("Token store was updated concurrently; aborting write");
return;
}
await tx.put<TokenStore>(TOKEN_STORE_KEY, {
accessToken,
refreshToken,
instanceUrl: store.instanceUrl,
expiresAt: typeof newExpiresAt === "number" ? newExpiresAt : store.expiresAt,
lastSeenAt: store.lastSeenAt,
});
});References
- Operations that involve reading and then writing to storage, especially when multiple clients might access the same data, should be performed within a transaction to ensure atomicity and prevent race conditions or data inconsistencies, unless the system design explicitly guarantees single-client access.
| const previousOrgId = | ||
| await this.state.storage.get<string>(ACTIVE_ORG_KEY); | ||
| await this.state.storage.put<string>( | ||
| ACTIVE_ORG_KEY, | ||
| body.activeOrgId, | ||
| ); | ||
| if (previousOrgId !== body.activeOrgId) { | ||
| await this.state.storage.delete(ORG_TOKEN_KEY); | ||
| } |
There was a problem hiding this comment.
When body.orgToken is not provided, the code always writes body.activeOrgId to ACTIVE_ORG_KEY even if it is identical to the previousOrgId. We can avoid this redundant write by only writing and deleting when the active org ID actually changes. Since this involves reading and then writing to storage, it should be performed within a transaction to ensure atomicity and prevent race conditions.
await this.state.storage.transaction(async (tx) => {
const previousOrgId = await tx.get<string>(ACTIVE_ORG_KEY);
if (previousOrgId !== body.activeOrgId) {
await tx.put<string>(ACTIVE_ORG_KEY, body.activeOrgId);
await tx.delete(ORG_TOKEN_KEY);
}
});References
- Operations that involve reading and then writing to storage, especially when multiple clients might access the same data, should be performed within a transaction to ensure atomicity and prevent race conditions or data inconsistencies, unless the system design explicitly guarantees single-client access.
| // No refresh token to re-seed with: use whatever we have. | ||
| this.warmGlobalToken = store.accessToken ?? accessToken; |
There was a problem hiding this comment.
In loadOrSeedWarmToken, if the stored token is expired and there is no refresh token to re-seed with, the fallback will still prefer the expired store.accessToken over the potentially fresh accessToken from this.ctx.props. We should check storedExpired before falling back to store.accessToken.
| // No refresh token to re-seed with: use whatever we have. | |
| this.warmGlobalToken = store.accessToken ?? accessToken; | |
| this.warmGlobalToken = (store.accessToken && !storedExpired) ? store.accessToken : accessToken; |
…over props In loadOrSeedWarmToken, when there's no refresh token to re-seed with, prefer the stored access token only if it isn't expired; otherwise fall back to the (possibly fresher) props token. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Load warm token before initializeService so getSessionInfo uses the keep-warm token instead of the short-lived props access token - Preserve lastSeenAt across re-seeds (only first seed + touch set it), so idle TTL tracks real tool-call activity not reconnects Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…clean up seedTokenStore - loadOrSeedWarmToken now reads the stored token FIRST; if it is a different (alarm-refreshed) token that is not expired, it wins over the incoming props token without any write at all. Removes the per-token-string comparison that was inside seedTokenStore, which the mock could not replicate. - seedTokenStore is now a plain write (no merge logic): the decision of which token to use has already been made by the caller. - Reverts TOKEN_REFRESH_INTERVAL_MS (11h) and SESSION_IDLE_TTL_MS (14d) from test values back to production. - Fixes broken fallback branch that referenced undefined `store` and `storedExpired` variables. - All 628 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The initializeService override that pre-loaded the warm token before getSessionInfo was not fixing the configInfo error (which is pre-existing and unrelated to this branch). Removes the override and restores postInit to call loadOrSeedWarmToken unconditionally. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…g hint - is_active field only included in response when true (saves tokens at scale) - ListOrgsOutputSchema updated to optional() to match - get_session_updates tool description: mention wrong org or no access when data not found Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
Adds multi-org support to the ThoughtSpot MCP server on top of the shared
@thoughtspot/mcp-authauth layer (post-#165 extraction):list_orgs/switch_orgv2 tools — list the user's orgs and switch the active org mid-session.OrgService— user-scoped org listing (session/orgs) + org-scoped token minting (auth/token/fetch?org_identifier=).UserTokenStoreSQLiteDO — per-user active org + org token + keep-warm cluster token (11h refresh alarm; 14-day idle abandonment). Keyed byhash(refreshToken)so it's shared across the client's fan-out.MCPServer: per-request active-org reload, org-scoped bearer (fail-closed — never the global token on a data call), reactive 401 re-mint (mint-first/atomic), org-tagged datasource cache.Backward compatibility
Gated on OAuth + Orgs-enabled + v2 + a multi-org grant (a refresh token in the grant, decided once in
postInit). A pre-multi-org grant (no refresh token) keeps the exact old behavior — no org tools, no overlay, its login token — until the user re-authenticates. Bearer/token and v1 sessions are unaffected.Dependency
Requires
@thoughtspot/mcp-auth@^2.0.0, which adds theextendGrantPropshook that carries the refresh token / expiry into the OAuth grant (needed for keep-warm). That must be published before CI is green.Testing
🤖 Generated with Claude Code