[POC] MCP Apps#163
Conversation
There was a problem hiding this comment.
Code Review
This pull request updates the @modelcontextprotocol/sdk dependency and introduces a new GetInteractiveAnswer tool and resource to render an interactive ThoughtSpot visualization inline in chat. Key feedback highlights critical security and architectural concerns, including a hardcoded trusted authentication token, a wildcard * target origin in postMessage communication, and hardcoded host and visualization IDs in the HTML template. The reviewer recommends retrieving credentials securely, restricting the postMessage target origin, and refactoring the tool and resource reading logic to dynamically parse and template the host, liveboard, and visualization parameters.
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.
| init({ | ||
| thoughtSpotHost, | ||
| authType: AuthType.TrustedAuthToken, | ||
| getAuthToken: () => Promise.resolve('cmlmZGhhbi5uYXplZXJAdGhvdWdodHNwb3QuY29tOk9EbGlOR05oTkRNdFltWmpNQzAwTmpWakxXRXpaakl0TkRFNU5UazJNMlptT1dOa09qRTNOems1TWpZNU9ERTRNemM2SkhOb2FYSnZNU1JUU0VFdE1qVTJKRFV3TURBd01DUmtVREZzVDFsRU9HcGpaMlZuYTNsc1lrczNSSGhCUFQwa1JXNUhielpoUzFjMWFuQXJiRGhHZFVoaVdXUnFNVVpUYzJGcWIydFVVV3BOV1RGalMzUjFWMHhUVFQw'), |
There was a problem hiding this comment.
The trusted authentication token is hardcoded in the source code. This token contains credentials associated with rifdhan.nazeer@thoughtspot.com and poses a severe security risk if committed to a public or shared repository.
Please remove this hardcoded credential. Instead, retrieve the token dynamically (e.g., by calling a token generation endpoint on your ThoughtSpot instance using the server's credentials) or pass it securely via configuration/environment variables (e.g., this.ctx.props.trustedAuthToken).
| const thoughtSpotHost = 'champagne-master-aws.thoughtspotstaging.cloud'; | ||
|
|
||
| init({ | ||
| thoughtSpotHost, | ||
| authType: AuthType.TrustedAuthToken, | ||
| getAuthToken: () => Promise.resolve('cmlmZGhhbi5uYXplZXJAdGhvdWdodHNwb3QuY29tOk9EbGlOR05oTkRNdFltWmpNQzAwTmpWakxXRXpaakl0TkRFNU5UazJNMlptT1dOa09qRTNOems1TWpZNU9ERTRNemM2SkhOb2FYSnZNU1JUU0VFdE1qVTJKRFV3TURBd01DUmtVREZzVDFsRU9HcGpaMlZuYTNsc1lrczNSSGhCUFQwa1JXNUhielpoUzFjMWFuQXJiRGhHZFVoaVdXUnFNVVpUYzJGcWIydFVVV3BOV1RGalMzUjFWMHhUVFQw'), | ||
| }); | ||
|
|
||
| const embed = new LiveboardEmbed(document.getElementById('ts-embed'), { | ||
| frameParams: { | ||
| width: '100%', | ||
| height: '100%', | ||
| }, | ||
| liveboardId: '31cbf421-b859-44ff-ab86-839197bb2bf2', | ||
| vizId: 'a9ff85e8-c28f-4189-a59a-c9dd2635ab0c', | ||
| }); |
There was a problem hiding this comment.
The ThoughtSpot host, liveboard ID, and visualization ID are currently hardcoded in the HTML template. This limits the interactive answer tool to a single static visualization on a specific staging environment, and will fail or violate CSP if the MCP server is connected to a different ThoughtSpot instance.
We can make this dynamic by using placeholders (e.g., __THOUGHTSPOT_HOST__, __LIVEBOARD_ID__, __VIZ_ID__) and replacing them dynamically when reading the resource.
| const thoughtSpotHost = 'champagne-master-aws.thoughtspotstaging.cloud'; | |
| init({ | |
| thoughtSpotHost, | |
| authType: AuthType.TrustedAuthToken, | |
| getAuthToken: () => Promise.resolve('cmlmZGhhbi5uYXplZXJAdGhvdWdodHNwb3QuY29tOk9EbGlOR05oTkRNdFltWmpNQzAwTmpWakxXRXpaakl0TkRFNU5UazJNMlptT1dOa09qRTNOems1TWpZNU9ERTRNemM2SkhOb2FYSnZNU1JUU0VFdE1qVTJKRFV3TURBd01DUmtVREZzVDFsRU9HcGpaMlZuYTNsc1lrczNSSGhCUFQwa1JXNUhielpoUzFjMWFuQXJiRGhHZFVoaVdXUnFNVVpUYzJGcWIydFVVV3BOV1RGalMzUjFWMHhUVFQw'), | |
| }); | |
| const embed = new LiveboardEmbed(document.getElementById('ts-embed'), { | |
| frameParams: { | |
| width: '100%', | |
| height: '100%', | |
| }, | |
| liveboardId: '31cbf421-b859-44ff-ab86-839197bb2bf2', | |
| vizId: 'a9ff85e8-c28f-4189-a59a-c9dd2635ab0c', | |
| }); | |
| const thoughtSpotHost = '__THOUGHTSPOT_HOST__'; | |
| init({ | |
| thoughtSpotHost, | |
| authType: AuthType.TrustedAuthToken, | |
| getAuthToken: () => Promise.resolve('cmlmZGhhbi5uYXplZXJAdGhvdWdodHNwb3QuY29tOk9EbGlOR05oTkRNdFltWmpNQzAwTmpWakxXRXpaakl0TkRFNU5UazJNMlptT1dOa09qRTNOems1TWpZNU9ERTRNemM2SkhOb2FYSnZNU1JUU0VFdE1qVTJKRFV3TURBd01DUmtVREZzVDFsRU9HcGpaMlZuYTNsc1lrczNSSGhCUFQwa1JXNUhielpoUzFjMWFuQXJiRGhHZFVoaVdXUnFNVVpUYzJGcWIydFVVV3BOV1RGalMzUjFWMHhUVFQw'), | |
| }); | |
| const embed = new LiveboardEmbed(document.getElementById('ts-embed'), { | |
| frameParams: { | |
| width: '100%', | |
| height: '100%', | |
| }, | |
| liveboardId: '__LIVEBOARD_ID__', | |
| vizId: '__VIZ_ID__', | |
| }); |
| if (uri === INTERACTIVE_ANSWER_RESOURCE_URI) { | ||
| const cspMeta = { | ||
| ui: { | ||
| csp: { | ||
| // Allow the SDK's API calls to the ThoughtSpot instance | ||
| connectDomains: [this.ctx.props.instanceUrl, "api-js.mixpanel.com"], | ||
| // Allow loading the SDK script from the CDN, and allow the | ||
| // ThoughtSpot SDK to embed the ThoughtSpot host in an iframe (frame-src) | ||
| resourceDomains: [ | ||
| "https://cdn.jsdelivr.net", | ||
| this.ctx.props.instanceUrl, | ||
| ], | ||
| // This is not being used but we need it... | ||
| frameDomains: [this.ctx.props.instanceUrl], | ||
| }, | ||
| }, | ||
| }; | ||
| return { | ||
| contents: [ | ||
| { | ||
| uri, | ||
| mimeType: "text/html;profile=mcp-app", | ||
| text: INTERACTIVE_ANSWER_HTML, | ||
| _meta: cspMeta, | ||
| }, | ||
| ], | ||
| }; | ||
| } |
There was a problem hiding this comment.
Update readResource to support dynamic parameter parsing from the resource URI. This allows the client to request any liveboard or visualization dynamically by passing query parameters (e.g., ?liveboardId=...&vizId=...), and automatically templates the host domain using this.ctx.props.instanceUrl.
if (uri.startsWith(INTERACTIVE_ANSWER_RESOURCE_URI)) {
const url = new URL(uri);
const liveboardId = url.searchParams.get("liveboardId") || "31cbf421-b859-44ff-ab86-839197bb2bf2";
const vizId = url.searchParams.get("vizId") || "a9ff85e8-c28f-4189-a59a-c9dd2635ab0c";
const host = new URL(this.ctx.props.instanceUrl).host;
const html = INTERACTIVE_ANSWER_HTML
.replace("__THOUGHTSPOT_HOST__", host)
.replace("__LIVEBOARD_ID__", liveboardId)
.replace("__VIZ_ID__", vizId);
const cspMeta = {
ui: {
csp: {
// Allow the SDK's API calls to the ThoughtSpot instance
connectDomains: [this.ctx.props.instanceUrl, "api-js.mixpanel.com"],
// Allow loading the SDK script from the CDN, and allow the
// ThoughtSpot SDK to embed the ThoughtSpot host in an iframe (frame-src)
resourceDomains: [
"https://cdn.jsdelivr.net",
this.ctx.props.instanceUrl,
],
// This is not being used but we need it...
frameDomains: [this.ctx.props.instanceUrl],
},
},
};
return {
contents: [
{
uri,
mimeType: "text/html;profile=mcp-app",
text: html,
_meta: cspMeta,
},
],
};
}| case ToolName.GetInteractiveAnswer: { | ||
| return this.callGetInteractiveAnswer(); | ||
| } |
There was a problem hiding this comment.
Pass the tool request arguments to callGetInteractiveAnswer so that the LLM can dynamically specify which liveboard or visualization to display.
| case ToolName.GetInteractiveAnswer: { | |
| return this.callGetInteractiveAnswer(); | |
| } | |
| case ToolName.GetInteractiveAnswer: { | |
| return this.callGetInteractiveAnswer(request); | |
| } |
| callGetInteractiveAnswer() { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text" as const, | ||
| text: "Interactive answer rendered. The UI is displayed inline in the chat.", | ||
| }, | ||
| ], | ||
| }; | ||
| } |
There was a problem hiding this comment.
Update callGetInteractiveAnswer to accept the tool request, parse the liveboardId and vizId arguments, and return a dynamic resource URI with query parameters in _meta.ui.resourceUri.
callGetInteractiveAnswer(request: z.infer<typeof CallToolRequestSchema>) {
const { liveboardId, vizId } = request.params.arguments as { liveboardId?: string; vizId?: string } || {};
const queryParams = new URLSearchParams();
if (liveboardId) queryParams.set("liveboardId", liveboardId);
if (vizId) queryParams.set("vizId", vizId);
const queryString = queryParams.toString();
const resourceUri = queryString ? `${INTERACTIVE_ANSWER_RESOURCE_URI}?${queryString}` : INTERACTIVE_ANSWER_RESOURCE_URI;
return {
content: [
{
type: "text" as const,
text: "Interactive answer rendered. The UI is displayed inline in the chat.",
},
],
_meta: {
ui: {
resourceUri,
},
},
};
}| { | ||
| name: ToolName.GetInteractiveAnswer, | ||
| description: | ||
| "Returns an interactive HTML application for exploring and visualising answers. Use this when the user asks for an interactive view, a visual answer, or an embedded app experience.", | ||
| inputSchema: z.toJSONSchema(z.object({})), | ||
| annotations: { | ||
| title: "Get Interactive Answer", | ||
| readOnlyHint: true, | ||
| destructiveHint: false, | ||
| openWorldHint: false, | ||
| }, | ||
| _meta: { | ||
| ui: { | ||
| resourceUri: INTERACTIVE_ANSWER_RESOURCE_URI, | ||
| }, | ||
| }, | ||
| }, |
There was a problem hiding this comment.
Update the tool definition for GetInteractiveAnswer to accept optional liveboardId and vizId parameters. This allows the LLM to dynamically request specific visualizations or liveboards (such as those it just created).
{
name: ToolName.GetInteractiveAnswer,
description:
"Returns an interactive HTML application for exploring and visualising answers. Use this when the user asks for an interactive view, a visual answer, or an embedded app experience.",
inputSchema: z.toJSONSchema(
z.object({
liveboardId: z.string().optional().describe("The ID of the liveboard to display"),
vizId: z.string().optional().describe("The ID of the specific visualization to display"),
})
),
annotations: {
title: "Get Interactive Answer",
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
},
_meta: {
ui: {
resourceUri: INTERACTIVE_ANSWER_RESOURCE_URI,
},
},
},| window.parent.postMessage({ jsonrpc: '2.0', id: id, method: method, params: params }, '*'); | ||
| }); | ||
| } | ||
|
|
||
| function sendNotification(method, params) { | ||
| window.parent.postMessage({ jsonrpc: '2.0', method: method, params: params || {} }, '*'); |
There was a problem hiding this comment.
Using * as the target origin in postMessage allows any origin to intercept the messages sent by the iframe. This can lead to sensitive data leakage if the iframe is embedded in an untrusted parent window.
Consider restricting the target origin to the expected host client origin. If the parent origin is dynamic, you can pass it as a query parameter to the iframe (e.g., ?parentOrigin=...) and use that value instead of *.
No description provided.