Skip to content

SCAL-319970: Multi-org support (list_orgs / switch_org) on shared mcp-auth - #173

Open
rohitthughtspot wants to merge 11 commits into
thoughtspot:mainfrom
rohitthughtspot:SCAL-319970-on-main
Open

SCAL-319970: Multi-org support (list_orgs / switch_org) on shared mcp-auth#173
rohitthughtspot wants to merge 11 commits into
thoughtspot:mainfrom
rohitthughtspot:SCAL-319970-on-main

Conversation

@rohitthughtspot

Copy link
Copy Markdown
Contributor

Summary

Adds multi-org support to the ThoughtSpot MCP server on top of the shared @thoughtspot/mcp-auth auth layer (post-#165 extraction):

  • list_orgs / switch_org v2 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=).
  • UserTokenStoreSQLite DO — per-user active org + org token + keep-warm cluster token (11h refresh alarm; 14-day idle abandonment). Keyed by hash(refreshToken) so it's shared across the client's fan-out.
  • Org overlay in 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 the extendGrantProps hook that carries the refresh token / expiry into the OAuth grant (needed for keep-warm). That must be published before CI is green.

Testing

  • Unit/integration: 627 passing (org tools/gate, isolation, fan-out last-write-wins, F2a mint-first re-mint, org-tagged cache, keep-warm lifecycle, backward-compat gate, listOrgs mapping edges, switch_org 4xx/5xx).
  • Live E2E on a test worker: list/switch/bad-org-no-op, cross-org data isolation (disjoint datasets), keep-warm refresh cycle + idle abandonment verified.

🤖 Generated with Claude Code

rohitthughtspot and others added 5 commits July 3, 2026 12:42
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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +187 to +195
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,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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
  1. 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.

Comment on lines +58 to +66
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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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
  1. 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.

Comment thread src/servers/mcp-server.ts Outdated
Comment on lines +281 to +282
// No refresh token to re-seed with: use whatever we have.
this.warmGlobalToken = store.accessToken ?? accessToken;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
// No refresh token to re-seed with: use whatever we have.
this.warmGlobalToken = store.accessToken ?? accessToken;
this.warmGlobalToken = (store.accessToken && !storedExpired) ? store.accessToken : accessToken;

rohitthughtspot and others added 6 commits July 4, 2026 22:22
…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>
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.

1 participant