• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

thoughtspot / mcp-server / 16209157863

11 Jul 2025 12:46AM UTC coverage: 89.668% (+1.2%) from 88.438%
16209157863

push

github

web-flow
Honeycomb traces (#34)

* Integrate with honeycomb otel library to get traces from MCP server in honeycomb

* use decorator patter using annotations to add tracing

* update error message

146 of 171 branches covered (85.38%)

Branch coverage included in aggregate %.

346 of 378 new or added lines in 8 files covered. (91.53%)

1 existing line in 1 file now uncovered.

557 of 613 relevant lines covered (90.86%)

102.38 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.43
/src/servers/mcp-server.ts
1
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
import {
3
    CallToolRequestSchema,
4
    ListToolsRequestSchema,
5
    ToolSchema,
6
    ListResourcesRequestSchema,
7
    ReadResourceRequestSchema
8
} from "@modelcontextprotocol/sdk/types.js";
9
import { z } from "zod";
10
import { zodToJsonSchema } from "zod-to-json-schema";
11
import type { Props } from "../utils";
12
import { McpServerError } from "../utils";
13
import { getThoughtSpotClient } from "../thoughtspot/thoughtspot-client";
14
import {
15
    ThoughtSpotService,
16
    type DataSource
17
} from "../thoughtspot/thoughtspot-service";
18
import { MixpanelTracker } from "../metrics/mixpanel/mixpanel";
19
import { Trackers, type Tracker, TrackEvent } from "../metrics";
20
import { context, type Span, SpanStatusCode, trace } from "@opentelemetry/api";
21
import { getActiveSpan, WithSpan } from "../metrics/tracing/tracing-utils";
22

23
const ToolInputSchema = ToolSchema.shape.inputSchema;
38✔
24
type ToolInput = z.infer<typeof ToolInputSchema>;
25

26
const PingSchema = z.object({});
38✔
27

28
const GetRelevantQuestionsSchema = z.object({
38✔
29
    query: z.string().describe("The query to get relevant data questions for, this could be a high level task or question the user is asking or hoping to get answered. Do minimal processing of the original question. You can even pass the complete raw query as it is, the system is smart to make sense of it as it has access to the entire schema. Do not add analytical hints or directions."),
30
    additionalContext: z.string()
31
        .describe("Additional context to add to the query, this might be older data returned for previous questions or any other relevant context that might help the system generate better questions.")
32
        .optional(),
33
    datasourceIds: z.array(z.string())
34
        .describe("The datasources to get questions for, this is the ids of the datasources to get data from. Each id is a GUID string.")
35
});
36

37
const GetAnswerSchema = z.object({
38✔
38
    question: z.string().describe("The question to get the answer for, these are generally the questions generated by the getRelevantQuestions tool."),
39
    datasourceId: z.string()
40
        .describe("The datasource to get the answer for, this is the id of the datasource to get data from")
41
});
42

43
const CreateLiveboardSchema = z.object({
38✔
44
    name: z.string().describe("The name of the liveboard to create"),
45
    answers: z.array(z.object({
46
        question: z.string(),
47
        session_identifier: z.string(),
48
        generation_number: z.number(),
49
    })).describe("The answers to create the liveboard from, these are the answers generated by the getAnswer tool."),
50
});
51

52
enum ToolName {
38✔
53
    Ping = "ping",
38✔
54
    GetRelevantQuestions = "getRelevantQuestions",
38✔
55
    GetAnswer = "getAnswer",
38✔
56
    CreateLiveboard = "createLiveboard",
38✔
57
}
58

59
interface Context {
60
    props: Props;
61
}
62

63
// Response utility types
64
type ContentItem = {
65
    type: "text";
66
    text: string;
67
};
68

69
type SuccessResponse = {
70
    content: ContentItem[];
71
};
72

73
type ErrorResponse = {
74
    isError: true;
75
    content: ContentItem[];
76
};
77

78
type ToolResponse = SuccessResponse | ErrorResponse;
79

80
export class MCPServer extends Server {
81
    private trackers: Trackers = new Trackers();
202✔
82
    private sessionInfo: any;
83
    constructor(private ctx: Context) {
202✔
84
        super({
202✔
85
            name: "ThoughtSpot",
86
            version: "1.0.0",
87
        }, {
88
            capabilities: {
89
                tools: {},
90
                logging: {},
91
                completion: {},
92
                resources: {},
93
            }
94
        });
95
    }
96

97
    private getThoughtSpotService() {
98
        return new ThoughtSpotService(getThoughtSpotClient(this.ctx.props.instanceUrl, this.ctx.props.accessToken));
286✔
99
    }
100

101
    /**
102
     * Initialize span with common attributes (user_guid and instance_url)
103
     */
104
    private initSpanWithCommonAttributes(span: Span | undefined): void {
105
        span?.setAttributes({
165✔
106
            user_guid: this.sessionInfo.userGUID,
107
            instance_url: this.ctx.props.instanceUrl,
108
        });
109
    }
110

111
    /**
112
     * Create a standardized error response
113
     */
114
        private createErrorResponse(span: Span | undefined, message: string, statusMessage?: string): ErrorResponse {
115
        span?.setStatus({ code: SpanStatusCode.ERROR, message: statusMessage || message });
44!
116
        return {
44✔
117
            isError: true,
118
            content: [{ type: "text", text: `ERROR: ${message}` }],
119
        };
120
    }
121

122
    /**
123
     * Create a standardized success response with a single message
124
     */
125
    private createSuccessResponse(span: Span | undefined, message: string, statusMessage?: string): SuccessResponse {
126
        span?.setStatus({ code: SpanStatusCode.OK, message: statusMessage || message });
33✔
127
        return {
33✔
128
            content: [{ type: "text", text: message }],
129
        };
130
    }
131

132
    /**
133
     * Create a standardized success response with multiple content items
134
     */
135
    private createMultiContentSuccessResponse(span: Span | undefined, content: ContentItem[], statusMessage: string): SuccessResponse {
136
        span?.setStatus({ code: SpanStatusCode.OK, message: statusMessage });
11✔
137
        return {
11✔
138
            content,
139
        };
140
    }
141

142
    /**
143
     * Create a standardized success response with an array of text items
144
     */
145
    private createArraySuccessResponse(span: Span | undefined, texts: string[], statusMessage: string): SuccessResponse {
146
        span?.setStatus({ code: SpanStatusCode.OK, message: statusMessage });
22✔
147
        return {
22✔
148
            content: texts.map(text => ({ type: "text", text })),
44✔
149
        };
150
    }
151

152
    async init() {
153
        this.sessionInfo = await this.getThoughtSpotService().getSessionInfo();
176✔
154
        const mixpanel = new MixpanelTracker(
176✔
155
            this.sessionInfo,
156
            this.ctx.props.clientName
157
        );
158
        this.addTracker(mixpanel);
176✔
159
        this.trackers.track(TrackEvent.Init);
176✔
160

161
        this.setRequestHandler(ListToolsRequestSchema, async () => {
176✔
162
            return this.listTools();
22✔
163
        });
164

165
        this.setRequestHandler(ListResourcesRequestSchema, async () => {
176✔
166
            return this.listResources();
33✔
167
        });
168

169
        this.setRequestHandler(ReadResourceRequestSchema, async (request: z.infer<typeof ReadResourceRequestSchema>) => {
176✔
NEW
170
            return this.readResource(request);
×
171
        });
172

173
        // Handle call tool request
174
        this.setRequestHandler(CallToolRequestSchema, async (request: z.infer<typeof CallToolRequestSchema>) => {
176✔
175
            return this.callTool(request);
110✔
176
        });
177
    }
178

179
    @WithSpan('list-tools')
180
    async listTools() {
38✔
181
        const span = getActiveSpan();
22✔
182
        this.initSpanWithCommonAttributes(span);
22✔
183
        
184
        return {
22✔
185
            tools: [
186
                {
187
                    name: ToolName.Ping,
188
                    description: "Simple ping tool to test connectivity and Auth",
189
                    inputSchema: zodToJsonSchema(PingSchema) as ToolInput,
190
                },
191
                {
192
                    name: ToolName.GetRelevantQuestions,
193
                    description: "Get relevant data questions from ThoughtSpot database",
194
                    inputSchema: zodToJsonSchema(GetRelevantQuestionsSchema) as ToolInput,
195
                },
196
                {
197
                    name: ToolName.GetAnswer,
198
                    description: "Get the answer to a question from ThoughtSpot database",
199
                    inputSchema: zodToJsonSchema(GetAnswerSchema) as ToolInput,
200
                },
201
                {
202
                    name: ToolName.CreateLiveboard,
203
                    description: "Create a liveboard from a list of answers",
204
                    inputSchema: zodToJsonSchema(CreateLiveboardSchema) as ToolInput,
205
                }
206
            ]
207
        };
208
    }
209

210
    @WithSpan('list-datasources')
211
    async listResources() {
38✔
212
        const span = getActiveSpan();
33✔
213
        this.initSpanWithCommonAttributes(span);
33✔
214
        
215
        const sources = await this.getDatasources();
33✔
216
        return {
33✔
217
            resources: sources.list.map((s) => ({
66✔
218
                uri: `datasource:///${s.id}`,
219
                name: s.name,
220
                description: s.description,
221
                mimeType: "text/plain"
222
            }))
223
        };
224
    }
225

226
    @WithSpan('read-datasources')
227
    async readResource(request: z.infer<typeof ReadResourceRequestSchema>) {
38✔
NEW
228
        const span = getActiveSpan();
×
NEW
229
        this.initSpanWithCommonAttributes(span);
×
230
        
NEW
231
        const { uri } = request.params;
×
NEW
232
        const sourceId = uri.split("///").pop();
×
NEW
233
        if (!sourceId) {
×
NEW
234
            throw new McpServerError({ message: "Invalid datasource uri" }, 400);
×
235
        }
NEW
236
        const { map: sourceMap } = await this.getDatasources();
×
NEW
237
        const source = sourceMap.get(sourceId);
×
NEW
238
        if (!source) {
×
NEW
239
            throw new McpServerError({ message: "Datasource not found" }, 404);
×
240
        }
NEW
241
        return {
×
242
            contents: [{
243
                uri: uri,
244
                mimeType: "text/plain",
245
                text: `
246
                ${source.description}
247

248
                The id of the datasource is ${sourceId}.
249

250
                Use ThoughtSpot's getRelevantQuestions tool to get relevant questions for a query. And then use the getAnswer tool to get the answer for a question.
251
                `,
252
            }],
253
        };
254
    }
255

256
    @WithSpan('call-tool')
257
    async callTool(request: z.infer<typeof CallToolRequestSchema>) {
38✔
258
        const { name } = request.params;
110✔
259
        this.trackers.track(TrackEvent.CallTool, { toolName: name });
110✔
260

261
        const span = getActiveSpan();
110✔
262
        this.initSpanWithCommonAttributes(span);
110✔
263

264
        let response: ToolResponse | undefined;
265
        switch (name) {
110!
266
            case ToolName.Ping: {
267
                console.log("Received Ping request");
22✔
268
                if (this.ctx.props.accessToken && this.ctx.props.instanceUrl) {
22✔
269
                    return this.createSuccessResponse(span, "Pong", "Ping successful");
11✔
270
                }
271
                return this.createErrorResponse(span, "Not authenticated", "Ping failed");
11✔
272
            }
273
            case ToolName.GetRelevantQuestions: {
274
                return this.callGetRelevantQuestions(request);
44✔
275
            }
276

277
            case ToolName.GetAnswer: {
278
                return this.callGetAnswer(request);
22✔
279
            }
280

281
            case ToolName.CreateLiveboard: {
282
                return this.callCreateLiveboard(request);
22✔
283
            }
284

285
            default:
NEW
286
                throw new Error(`Unknown tool: ${name}`);
×
287
        }
288
    }
289

290
    @WithSpan('call-get-relevant-questions')
291
    async callGetRelevantQuestions(request: z.infer<typeof CallToolRequestSchema>) {
38✔
292
        const { query, datasourceIds: sourceIds, additionalContext } = GetRelevantQuestionsSchema.parse(request.params.arguments);
44✔
293
        console.log("[DEBUG] Getting relevant questions for datasource: ", sourceIds);
44✔
294
        const span = getActiveSpan();
44✔
295

296
        const relevantQuestions = await this.getThoughtSpotService().getRelevantQuestions(
44✔
297
            query,
298
            sourceIds!,
299
            additionalContext ?? ""
77✔
300
        );
301

302
        if (relevantQuestions.error) {
44✔
303
            return this.createErrorResponse(span, relevantQuestions.error.message, `Error getting relevant questions ${relevantQuestions.error.message}`);
11✔
304
        }
305

306
        if (relevantQuestions.questions.length === 0) {
33✔
307
            return this.createSuccessResponse(span, "No relevant questions found");
11✔
308
        }
309

310
        const questionTexts = relevantQuestions.questions.map(q => 
22✔
311
            `Question: ${q.question}\nDatasourceId: ${q.datasourceId}`
44✔
312
        );
313
        
314
        return this.createArraySuccessResponse(span, questionTexts, "Relevant questions found");
22✔
315
    }
316

317
    @WithSpan('call-get-answer')
318
    async callGetAnswer(request: z.infer<typeof CallToolRequestSchema>) {
38✔
319
        const { question, datasourceId: sourceId } = GetAnswerSchema.parse(request.params.arguments);
22✔
320
        const span = getActiveSpan();
22✔
321

322
        const answer = await this.getThoughtSpotService().getAnswerForQuestion(question, sourceId, false);
22✔
323

324
        if (answer.error) {
22✔
325
            return this.createErrorResponse(span, answer.error.message, `Error getting answer ${answer.error.message}`);
11✔
326
        }
327

328
        const content: ContentItem[] = [
11✔
329
            { type: "text", text: answer.data },
330
            {
331
                type: "text",
332
                text: `Question: ${question}\nSession Identifier: ${answer.session_identifier}\nGeneration Number: ${answer.generation_number}\n\nUse this information to create a liveboard with the createLiveboard tool, if the user asks.`,
333
            },
334
        ];
335
        return this.createMultiContentSuccessResponse(span, content, "Answer found");
11✔
336
    }
337

338
    @WithSpan('call-create-liveboard')
339
    async callCreateLiveboard(request: z.infer<typeof CallToolRequestSchema>) {
38✔
340
        const { name, answers } = CreateLiveboardSchema.parse(request.params.arguments);
22✔
341
        const liveboard = await this.getThoughtSpotService().fetchTMLAndCreateLiveboard(name, answers);
22✔
342
        const span = getActiveSpan();
22✔
343
        
344
        if (liveboard.error) {
22✔
345
            return this.createErrorResponse(span, liveboard.error.message, `Error creating liveboard ${liveboard.error.message}`);
11✔
346
        }
347

348
        const successMessage = `Liveboard created successfully, you can view it at ${liveboard.url}
11✔
349
                
350
Provide this url to the user as a link to view the liveboard in ThoughtSpot.`;
351

352
        return this.createSuccessResponse(span, successMessage, "Liveboard created successfully");
11✔
353
    }
354

355
    private _sources: {
356
        list: DataSource[];
357
        map: Map<string, DataSource>;
358
    } | null = null;
202✔
359

360
    @WithSpan('get-datasources')
361
    async getDatasources() {
38✔
362
        if (this._sources) {
33✔
363
            return this._sources;
11✔
364
        }
365

366
        const sources = await this.getThoughtSpotService().getDataSources();
22✔
367
        this._sources = {
22✔
368
            list: sources,
369
            map: new Map(sources.map(s => [s.id, s])),
44✔
370
        }
371
        return this._sources;
22✔
372
    }
373

374
    async addTracker(tracker: Tracker) {
375
        this.trackers.add(tracker);
176✔
376
    }
377
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc