Skip to content

SCAL-319970: Multi-org support (list_orgs / switch_org, keep-warm token, idle TTL)#176

Open
rohitthughtspot wants to merge 11 commits into
thoughtspot:mainfrom
rohitthughtspot:SCAL-319970-multi-org
Open

SCAL-319970: Multi-org support (list_orgs / switch_org, keep-warm token, idle TTL)#176
rohitthughtspot wants to merge 11 commits into
thoughtspot:mainfrom
rohitthughtspot:SCAL-319970-multi-org

Conversation

@rohitthughtspot

Copy link
Copy Markdown

Summary

  • list_orgs / switch_org tools: 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/fetch and persisted in durable storage so concurrent sessions share one mint.
  • Keep-warm token (UserTokenStoreSQLite Durable Object): a DO alarm fires every 11h to call gettoken?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.
  • Backward compat: grants minted before multi-org shipped have no refresh token → org tools stay hidden until re-auth. No code change needed for existing users.
  • resources: { listChanged: true } capability: switch_org sends a notifications/resources/list_changed notification so clients re-fetch the new org's datasources.
  • @thoughtspot/mcp-auth bumped to ^2.0.0: picks up the refreshToken field added to the OAuth grant.
  • is_active omitted for inactive orgs: response only includes is_active: true on the active org, saving tokens at scale.
  • Wrong-org hint in 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 suggest list_orgs / switch_org.
  • Tests: 628 passing — new suites cover UserTokenStoreSQLite (keep-warm alarm, idle TTL, lastSeenAt), MCPServer org flows (list, switch, token retry, backward compat), and OrgService.

Test plan

  • Connect with an OAuth session — list_orgs appears in tool list on an orgs-enabled cluster
  • Call list_orgs — returns org list; only active org has is_active: true, others have no is_active field
  • Call switch_org with a valid org ID — subsequent data calls use org-scoped token
  • Call switch_org with an invalid org ID — returns error, active org unchanged
  • Connect with a legacy grant (no refresh token) — list_orgs/switch_org absent from tool list
  • Verify keep-warm alarm fires at ~11h intervals in wrangler tail
  • npm test — 628 passed

🤖 Generated with Claude Code

rohitthughtspot and others added 10 commits July 7, 2026 21:56
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>

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

Comment thread src/servers/mcp-server.ts Outdated
Comment on lines +1034 to +1038
await this.sendResourceListChanged();
// TEMP (testing): confirm the notification path fires on switch. Remove.
console.log(
`[RESOURCE-DEBUG] sendResourceListChanged sent after switch to org ${orgId}`,
);

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

Please remove the temporary debugging comment and console.log statement before merging.

Suggested change
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();

@rohitthughtspot rohitthughtspot Jul 7, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

done

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

getActiveOrgToken

// 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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why do we have a dummy implementation instead of just a stub like we have for postInit?

Comment thread src/servers/mcp-server.ts
export class MCPServer extends BaseMCPServer {
private activeOrgId: string | undefined;
private activeOrgToken: string | undefined;
private warmGlobalToken: string | undefined;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't think "warm" is needed, let's just call it globalToken

Comment thread src/servers/mcp-server.ts
}

private getGlobalToken(): string {
return this.warmGlobalToken ?? this.ctx.props.accessToken;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

accessToken is not a global token, either the function name should be different or this is incorrect logic?

Comment thread src/servers/mcp-server.ts
return this.warmGlobalToken ?? this.ctx.props.accessToken;
}

protected getActiveOrgId(): string | undefined {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

GLOBAL_TOKEN_REFRESH_INTERVAL_MS

const SESSION_IDLE_TTL_MS = 14 * 24 * 60 * 60 * 1000;

export type TokenStore = {
accessToken: string;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

globalToken


export type TokenStore = {
accessToken: string;
refreshToken: string;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

globalRefreshToken

return Response.json({ ok: true });
}

case "POST /touch": {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

POST /last-seen

const TOKEN_REFRESH_INTERVAL_MS = 11 * 60 * 60 * 1000;
const SESSION_IDLE_TTL_MS = 14 * 24 * 60 * 60 * 1000;

export type TokenStore = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

GlobalTokenData

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.

2 participants