SCAL-319970: Multi-org support (list_orgs / switch_org, keep-warm token, idle TTL)#176
SCAL-319970: Multi-org support (list_orgs / switch_org, keep-warm token, idle TTL)#176rohitthughtspot 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>
…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>
There was a problem hiding this comment.
Code Review
This pull request introduces multi-org support to the ThoughtSpot MCP server. It adds a new Durable Object, UserTokenStoreSQLite, to manage active org states, org-scoped tokens, and keep-warm token refreshes. It also introduces two new tools, list_orgs and switch_org, along with reactive 401 token re-minting and retry logic. The reviewer's feedback recommends removing a temporary debugging comment and console.log statement from the switch_org implementation in mcp-server.ts.
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.sendResourceListChanged(); | ||
| // TEMP (testing): confirm the notification path fires on switch. Remove. | ||
| console.log( | ||
| `[RESOURCE-DEBUG] sendResourceListChanged sent after switch to org ${orgId}`, | ||
| ); |
There was a problem hiding this comment.
Please remove the temporary debugging comment and console.log statement before merging.
| await this.sendResourceListChanged(); | |
| // TEMP (testing): confirm the notification path fires on switch. Remove. | |
| console.log( | |
| `[RESOURCE-DEBUG] sendResourceListChanged sent after switch to org ${orgId}`, | |
| ); | |
| await this.sendResourceListChanged(); |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
|
||
| // Bearer token for ThoughtSpot calls; the session token by default. Subclasses | ||
| // override to return an org-scoped token. | ||
| protected getActiveBearerToken(): string { |
| // Active org for ThoughtSpot calls (x-thoughtspot-orgs header); none by default. | ||
| // Subclasses override to supply per-session org state. | ||
| protected getActiveOrgId(): string | undefined { | ||
| return undefined; |
There was a problem hiding this comment.
Why do we have a dummy implementation instead of just a stub like we have for postInit?
| export class MCPServer extends BaseMCPServer { | ||
| private activeOrgId: string | undefined; | ||
| private activeOrgToken: string | undefined; | ||
| private warmGlobalToken: string | undefined; |
There was a problem hiding this comment.
I don't think "warm" is needed, let's just call it globalToken
| } | ||
|
|
||
| private getGlobalToken(): string { | ||
| return this.warmGlobalToken ?? this.ctx.props.accessToken; |
There was a problem hiding this comment.
accessToken is not a global token, either the function name should be different or this is incorrect logic?
| return this.warmGlobalToken ?? this.ctx.props.accessToken; | ||
| } | ||
|
|
||
| protected getActiveOrgId(): string | undefined { |
There was a problem hiding this comment.
Nit: no need for a wrapper function, just use the private property
| const ACTIVE_ORG_KEY = "active-org"; | ||
| const ORG_TOKEN_KEY = "active-org-token"; | ||
| const TOKEN_STORE_KEY = "token-store"; | ||
| const TOKEN_REFRESH_INTERVAL_MS = 11 * 60 * 60 * 1000; |
There was a problem hiding this comment.
GLOBAL_TOKEN_REFRESH_INTERVAL_MS
| const SESSION_IDLE_TTL_MS = 14 * 24 * 60 * 60 * 1000; | ||
|
|
||
| export type TokenStore = { | ||
| accessToken: string; |
|
|
||
| export type TokenStore = { | ||
| accessToken: string; | ||
| refreshToken: string; |
| return Response.json({ ok: true }); | ||
| } | ||
|
|
||
| case "POST /touch": { |
| const TOKEN_REFRESH_INTERVAL_MS = 11 * 60 * 60 * 1000; | ||
| const SESSION_IDLE_TTL_MS = 14 * 24 * 60 * 60 * 1000; | ||
|
|
||
| export type TokenStore = { |
Summary
list_orgs/switch_orgtools: new MCP tools gated on OAuth + orgs-enabled cluster + v2 API surface + a post-multi-org grant (refresh token present). Org-scoped bearer tokens are minted via/callosum/v1/v2/auth/token/fetchand persisted in durable storage so concurrent sessions share one mint.UserTokenStoreSQLiteDurable Object): a DO alarm fires every 11h to callgettoken?refresh=true, preventing the 24h token expiry from killing long-idle sessions. Idle TTL of 14 days abandons storage for users who haven't connected in two weeks.loadOrSeedWarmToken: read-before-write on connect — if the alarm-refreshed token is newer than the props token (different string, not expired), use it directly; otherwise seed from the fresh OAuth grant.resources: { listChanged: true }capability:switch_orgsends anotifications/resources/list_changednotification so clients re-fetch the new org's datasources.@thoughtspot/mcp-authbumped to^2.0.0: picks up therefreshTokenfield added to the OAuth grant.is_activeomitted for inactive orgs: response only includesis_active: trueon the active org, saving tokens at scale.get_session_updates: when no data is found, the LLM is told the data may be in a different org or the user may not have access, and to suggestlist_orgs/switch_org.UserTokenStoreSQLite(keep-warm alarm, idle TTL, lastSeenAt),MCPServerorg flows (list, switch, token retry, backward compat), andOrgService.Test plan
list_orgsappears in tool list on an orgs-enabled clusterlist_orgs— returns org list; only active org hasis_active: true, others have nois_activefieldswitch_orgwith a valid org ID — subsequent data calls use org-scoped tokenswitch_orgwith an invalid org ID — returns error, active org unchangedlist_orgs/switch_orgabsent from tool listnpm test— 628 passed🤖 Generated with Claude Code