Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 38 additions & 5 deletions src/servers/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ export class MCPServer extends BaseMCPServer {
switch (name) {
case ToolName.Ping: {
if (this.ctx.props.accessToken && this.ctx.props.instanceUrl) {
if (!this.getThoughtSpotService(recorder).validateConnection()) {
if (!this.getThoughtSpotService(recorder).validateConnection()) {
return this.createErrorResponse(
"Failed to validate connection",
"Ping failed",
Expand Down Expand Up @@ -383,6 +383,38 @@ Provide this url to the user as a link to view the liveboard in ThoughtSpot.`;
);
}

/**
* Spotter chat history (save_chat_enabled) is gated on BOTH:
* - the tenant's configInfo.saveChatEnabled flag, AND
* - the cluster being in the SPOTTER_CHAT_HISTORY_CLUSTER_IDS allowlist
* (comma-separated clusterIds).
* Defaults to false when the tenant flag is off, the allowlist is unset/empty,
* or the cluster is not listed.
*/
private isSpotterChatHistoryEnabled(): boolean {
const tenantEnabled = !!this.sessionInfo?.saveChatEnabled;
if (!tenantEnabled) {
return false;
}

const clusterId = this.sessionInfo?.clusterId;
if (!clusterId) {
return false;
}

const rawAllowlist = (this.ctx.env as Record<string, unknown> | undefined)
?.SPOTTER_CHAT_HISTORY_CLUSTER_IDS;
if (typeof rawAllowlist !== "string" || rawAllowlist.length === 0) {
return false;
}

const allowlist = rawAllowlist
.split(",")
.map((id) => id.trim());

return allowlist.includes(String(clusterId));
}

@WithSpan("call-create-analysis-session")
async callCreateAnalysisSession(
request: z.infer<typeof CallToolRequestSchema>,
Expand All @@ -396,10 +428,11 @@ Provide this url to the user as a link to view the liveboard in ThoughtSpot.`;

let response: AgentConversation;
try {
response =
await this.getThoughtSpotService(recorder).createAgentConversation(
data_source_id,
);
response = await this.getThoughtSpotService(
recorder,
).createAgentConversation(data_source_id, {
saveChatEnabled: this.isSpotterChatHistoryEnabled(),
});
} catch (error) {
if (!(error as any)?.message?.includes("failed with status 401")) {
throw error;
Expand Down
10 changes: 6 additions & 4 deletions src/thoughtspot/thoughtspot-client.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import {
createBearerAuthenticationConfig,
ThoughtSpotRestApi,
createBearerAuthenticationConfig,
} from "@thoughtspot/rest-api-sdk";
import type {
AgentConversation,
RequestContext,
ResponseContext,
} from "@thoughtspot/rest-api-sdk";
import YAML from "yaml";
import { customAlphabet } from "nanoid";
import { of } from "rxjs";
import YAML from "yaml";
import type { SessionInfo } from "./types";
import { customAlphabet } from "nanoid";

/*
* Inject custom handlers into the ThoughtSpot client
Expand Down Expand Up @@ -214,8 +214,10 @@ function addCreateAgentConversationWithAutoMode(
) {
(client as any).createAgentConversationWithAutoMode = async ({
dataSourceId,
saveChatEnabled,
}: {
dataSourceId?: string;
saveChatEnabled?: boolean;
}): Promise<AgentConversation> => {
const endpoint = "/conversation/v2/";
const fetchOptions = {
Expand All @@ -240,7 +242,7 @@ function addCreateAgentConversationWithAutoMode(
conv_settings: {
enable_nls: true,
enable_why: true,
save_chat_enabled: false,
save_chat_enabled: !!saveChatEnabled,
enable_tool_permissions: false,
enable_search_datasets: !dataSourceId,
enable_auto_select_dataset: !dataSourceId,
Expand Down
6 changes: 6 additions & 0 deletions src/thoughtspot/thoughtspot-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,9 @@ export class ThoughtSpotService {
@WithSpan("create-agent-conversation")
async createAgentConversation(
dataSourceId?: string,
opts?: {
saveChatEnabled: boolean;
},
): Promise<AgentConversation> {
const span = trace.getSpan(context.active());
span?.setAttribute("data_source_id", dataSourceId ?? "(none)");
Expand All @@ -322,6 +325,7 @@ export class ThoughtSpotService {
() =>
(this.client as any).createAgentConversationWithAutoMode({
dataSourceId,
saveChatEnabled: opts?.saveChatEnabled,
}),
);

Expand Down Expand Up @@ -714,6 +718,8 @@ export class ThoughtSpotService {
privileges: info.privileges,
enableSpotterDataSourceDiscovery:
info.configInfo?.enableSpotterDataSourceDiscovery,
saveChatEnabled:
info.configInfo?.showSpotterPastConversations,
};
}

Expand Down
1 change: 1 addition & 0 deletions src/thoughtspot/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface SessionInfo {
currentOrgId: string;
privileges: any;
enableSpotterDataSourceDiscovery?: boolean;
saveChatEnabled?: boolean;
}

export interface BaseMessage {
Expand Down
111 changes: 111 additions & 0 deletions test/servers/mcp-server-v2.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
* Verifies that SSE bytes produce the correct DO state end-to-end.
*/

import { connect } from "mcp-testing-kit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ConversationStorageServerSQLite } from "../../src";
import { MCPServer } from "../../src/servers/mcp-server";
Expand Down Expand Up @@ -540,3 +541,113 @@ describe("V2 Streaming Parser + Real Storage Integration", () => {
});
});
});

// ---------------------------------------------------------------------------
// Group 4 — create_analysis_session chat-history end-to-end
//
// Real service + real client (only `fetch` mocked). Exercises the chain:
// callCreateAnalysisSession → ThoughtSpotService.createAgentConversation
// → real client.createAgentConversationWithAutoMode (conv_settings body)
// so a break in threading the tenant chat-history flag would be caught here.
// ---------------------------------------------------------------------------

describe("V2 create_analysis_session chat history (real service + real client, fetch mocked)", () => {
let originalFetch: typeof global.fetch;

beforeEach(() => {
vi.clearAllMocks();
originalFetch = global.fetch;
});

afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});

function installFetch(configInfoOverrides: Record<string, unknown>) {
const calls: Array<{ url: string; init?: RequestInit }> = [];
global.fetch = vi.fn(async (url: any, init?: RequestInit) => {
const u = String(url);
calls.push({ url: u, init });
if (u.endsWith("/prism/preauth/info")) {
return {
ok: true,
json: async () => ({
info: {
userGUID: "user-int",
userName: "user-int",
releaseVersion: "10.13.0.cl-110",
currentOrgId: "org-int",
privileges: [],
configInfo: {
mixpanelConfig: {
production: false,
devSdkKey: "dev-key",
prodSdkKey: "prod-key",
},
selfClusterName: "cluster-int",
selfClusterId: "cluster-int-id",
...configInfoOverrides,
},
},
}),
} as unknown as Response;
}
if (u.endsWith("/conversation/v2/")) {
return {
ok: true,
json: async () => ({ conversation_id: "conv-int-4" }),
} as unknown as Response;
}
return {
ok: false,
status: 404,
text: async () => `unexpected url ${u}`,
} as unknown as Response;
}) as unknown as typeof global.fetch;
return calls;
}

it("threads showSpotterPastConversations into save_chat_enabled across the real service→client seam", async () => {
const calls = installFetch({ showSpotterPastConversations: true });

const server = new MCPServer({
props: mockProps,
// selfClusterId in installFetch is "cluster-int-id" — allowlist it so the gate opens
env: { SPOTTER_CHAT_HISTORY_CLUSTER_IDS: "cluster-int-id" },
} as any);
await server.init();
const { callTool } = connect(server);

const result = await callTool("create_analysis_session", {});

expect(result.isError).toBeUndefined();
expect((result.structuredContent as any).analytical_session_id).toBe(
"conv-int-4",
);

const convCall = calls.find((c) => c.url.endsWith("/conversation/v2/"));
expect(convCall).toBeDefined();
const body = JSON.parse(convCall?.init?.body as string);
expect(body.conv_settings.save_chat_enabled).toBe(true);
});

it("keeps save_chat_enabled false when the tenant flag is on but the cluster is not allowlisted", async () => {
const calls = installFetch({ showSpotterPastConversations: true });

const server = new MCPServer({
props: mockProps,
// installFetch cluster is "cluster-int-id" — allowlist a different one
env: { SPOTTER_CHAT_HISTORY_CLUSTER_IDS: "some-other-cluster" },
} as any);
await server.init();
const { callTool } = connect(server);

const result = await callTool("create_analysis_session", {});

expect(result.isError).toBeUndefined();
const convCall = calls.find((c) => c.url.endsWith("/conversation/v2/"));
const body = JSON.parse(convCall?.init?.body as string);
expect(body.conv_settings.save_chat_enabled).toBe(false);
});
});
102 changes: 102 additions & 0 deletions test/servers/mcp-server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1239,6 +1239,108 @@ describe("MCP Server", () => {
);
expect(mockCreateAgentConversationWithAutoMode).toHaveBeenCalledWith({
dataSourceId: "ds-123",
showSpotterPastConversations: false,
});
});

describe("chat history clusterId gating", () => {
// Builds a server whose session has the given tenant flag + selfClusterId,
// constructed with the given allowlist env var, then invokes the tool and
// returns the flag value passed to the client.
async function runWithGate({
tenantFlag,
selfClusterId,
allowlistEnv,
}: {
tenantFlag: boolean;
selfClusterId: string;
allowlistEnv?: string;
}): Promise<boolean> {
const mockCreateAgentConversationWithAutoMode = vi
.fn()
.mockResolvedValue({ conversation_id: "conv-chat" });

vi.spyOn(thoughtspotClient, "getThoughtSpotClient").mockReturnValue({
getSessionInfo: vi.fn().mockResolvedValue({
userGUID: "test-user-123",
userName: "test-user",
releaseVersion: "10.13.0.cl-110",
currentOrgId: "test-org",
privileges: [],
configInfo: {
mixpanelConfig: {
devSdkKey: "test-dev-token",
prodSdkKey: "test-prod-token",
production: false,
},
selfClusterName: "test-cluster",
selfClusterId,
showSpotterPastConversations: tenantFlag,
},
}),
createAgentConversationWithAutoMode:
mockCreateAgentConversationWithAutoMode,
instanceUrl: "https://test.thoughtspot.cloud",
} as any);

const gatedServer = new MCPServer({
props: mockProps,
env: (allowlistEnv === undefined
? {}
: {
SPOTTER_CHAT_HISTORY_CLUSTER_IDS: allowlistEnv,
}) as any,
});
await gatedServer.init();
const { callTool } = connect(gatedServer);

const result = await callTool("create_analysis_session", {
data_source_id: "ds-777",
});
expect(result.isError).toBeUndefined();

const call = mockCreateAgentConversationWithAutoMode.mock.calls[0][0];
return call.showSpotterPastConversations;
}

it("enables save_chat when tenant flag is on AND clusterId is allowlisted", async () => {
expect(
await runWithGate({
tenantFlag: true,
selfClusterId: "cluster-allowed",
allowlistEnv: "other-1,cluster-allowed,other-2",
}),
).toBe(true);
});

it("defaults to false when clusterId is not in the allowlist", async () => {
expect(
await runWithGate({
tenantFlag: true,
selfClusterId: "cluster-not-listed",
allowlistEnv: "cluster-allowed",
}),
).toBe(false);
});

it("defaults to false when the allowlist env var is unset", async () => {
expect(
await runWithGate({
tenantFlag: true,
selfClusterId: "cluster-allowed",
allowlistEnv: undefined,
}),
).toBe(false);
});

it("stays false when clusterId is allowlisted but the tenant flag is off", async () => {
expect(
await runWithGate({
tenantFlag: false,
selfClusterId: "cluster-allowed",
allowlistEnv: "cluster-allowed",
}),
).toBe(false);
});
});

Expand Down
Loading