diff --git a/src/handlers.ts b/src/handlers.ts index cc8f4d0..0113340 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -1,13 +1,15 @@ import type { AuthRequest, OAuthHelpers } from '@cloudflare/workers-oauth-provider' import { Hono } from 'hono' import type { Props } from './utils'; -import { McpServerError } from './utils'; +import { getFromKV, McpServerError } from './utils'; import { parseRedirectApproval, renderApprovalDialog, buildSamlRedirectUrl } from './oauth-manager/oauth-utils'; import { renderTokenCallback } from './oauth-manager/token-utils'; import { any } from 'zod'; import { encodeBase64Url, decodeBase64Url } from 'hono/utils/encode'; import { getActiveSpan, WithSpan } from './metrics/tracing/tracing-utils'; import { context, type Span, SpanStatusCode, trace } from "@opentelemetry/api"; +import { ThoughtSpotService } from './thoughtspot/thoughtspot-service'; +import { getThoughtSpotClient } from './thoughtspot/thoughtspot-client'; const app = new Hono<{ Bindings: Env & { OAUTH_PROVIDER: OAuthHelpers } }>() @@ -181,11 +183,49 @@ class Handler { return { redirectTo }; } + + @WithSpan('get-answer-image') + async getAnswerImage(request: Request, env: Env) { + const span = getActiveSpan(); + const url = new URL(request.url); + const token = url.searchParams.get('token') || url.searchParams.get('uniqueId') || ''; + if (token === '') { + return new Response("Token not found", { status: 404 }); + } + console.log("[DEBUG] Token", token); + + const tokenData = await getFromKV(token, env); + if (!tokenData) { + span?.setStatus({ code: SpanStatusCode.ERROR, message: "Token not found" }); + return new Response("Token not found", { status: 404 }); + } + console.log("[DEBUG] Token found", token); + + // Extract values from token data + const sessionId = (tokenData as any).sessionId; + const generationNo = (tokenData as any).GenNo || (tokenData as any).generationNo; // Handle both field names + const instanceURL = (tokenData as any).instanceURL; + const accessToken = (tokenData as any).accessToken; + + console.log("[DEBUG] Session ID", sessionId); + console.log("[DEBUG] Generation No", generationNo); + console.log("[DEBUG] Instance URL", instanceURL); + + const thoughtSpotService = new ThoughtSpotService(getThoughtSpotClient(instanceURL, accessToken)); + const image = await thoughtSpotService.getAnswerImagePNG(sessionId, generationNo); + span?.setStatus({ code: SpanStatusCode.OK, message: "Image fetched successfully" }); + return new Response(await image.arrayBuffer(), { + headers: { + 'Content-Type': 'image/png', + }, + }); + } } const handler = new Handler(); app.get("/", async (c) => { + console.log("[DEBUG] Serving index", c.env); const response = await handler.serveIndex(c.env); return response; }); @@ -263,4 +303,8 @@ app.post("/store-token", async (c) => { } }); +app.get("/data/img", async (c) => { + return handler.getAnswerImage(c.req.raw, c.env); +}); + export default app; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 7e3d5cc..03d2b2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,9 +14,9 @@ const config: ResolveConfigFn = (env: Env, _trigger) => { return { exporter: { url: 'https://api.honeycomb.io/v1/traces', - headers: { 'x-honeycomb-team': process.env.HONEYCOMB_API_KEY }, + headers: { 'x-honeycomb-team': env.HONEYCOMB_API_KEY }, }, - service: { name: process.env.HONEYCOMB_DATASET } + service: { name: env.HONEYCOMB_DATASET } }; }; diff --git a/src/servers/api-server.ts b/src/servers/api-server.ts index 563acf1..7a3b1ee 100644 --- a/src/servers/api-server.ts +++ b/src/servers/api-server.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono' import type { Props } from '../utils'; -import { McpServerError } from '../utils'; +import { getFromKV, McpServerError } from '../utils'; import { getDataSources, ThoughtSpotService } from '../thoughtspot/thoughtspot-service'; import { getThoughtSpotClient } from '../thoughtspot/thoughtspot-client'; import { getActiveSpan, WithSpan } from '../metrics/tracing/tracing-utils'; @@ -47,6 +47,12 @@ class ApiHandler { return await service.getDataSources(); } + @WithSpan('api-get-answer-image') + async getAnswerImage(props: Props, sessionId: string, generationNo: number) { + const service = this.getThoughtSpotService(props); + return await service.getAnswerImage(sessionId, generationNo); + } + @WithSpan('api-proxy-post') async proxyPost(props: Props, path: string, body: any) { const span = getActiveSpan(); diff --git a/src/servers/mcp-server-base.ts b/src/servers/mcp-server-base.ts index 2105432..607488a 100644 --- a/src/servers/mcp-server-base.ts +++ b/src/servers/mcp-server-base.ts @@ -11,7 +11,7 @@ import type { z } from "zod"; import { context, type Span, SpanStatusCode } from "@opentelemetry/api"; import { getActiveSpan, withSpan } from "../metrics/tracing/tracing-utils"; import { Trackers, type Tracker, TrackEvent } from "../metrics"; -import type { Props } from "../utils"; +import { putInKV, type Props } from "../utils"; import { MixpanelTracker } from "../metrics/mixpanel/mixpanel"; import { getThoughtSpotClient } from "../thoughtspot/thoughtspot-client"; import { ThoughtSpotService } from "../thoughtspot/thoughtspot-service"; @@ -39,6 +39,7 @@ export type ToolResponse = SuccessResponse | ErrorResponse; export interface Context { props: Props; + env?: Env; // Add access to Cloudflare Workers environment } export abstract class BaseMCPServer extends Server { @@ -147,6 +148,24 @@ export abstract class BaseMCPServer extends Server { this.addTracker(mixpanel); } + protected async getTokenUrl(sessionId: string, generationNo: number) { + let tokenUrl = ""; + // Generate token and store in KV store + if (this.ctx.env?.OAUTH_KV) { + console.log("[DEBUG] Storing token in KV"); + const token = crypto.randomUUID(); + const tokenData = { + sessionId: sessionId, + generationNo: generationNo, + instanceURL: this.ctx.props.instanceUrl, + accessToken: this.ctx.props.accessToken + }; + await putInKV(token, tokenData, this.ctx.env); + tokenUrl = `${this.ctx.env?.HOST_NAME}/data/img?uniqueId=${token}`; + } + return tokenUrl; + } + /** * Abstract method to be implemented by subclasses for listing tools */ diff --git a/src/servers/mcp-server.ts b/src/servers/mcp-server.ts index 3a39b30..8db23d4 100644 --- a/src/servers/mcp-server.ts +++ b/src/servers/mcp-server.ts @@ -224,6 +224,7 @@ export class MCPServer extends BaseMCPServer { const { question, datasourceId: sourceId } = GetAnswerSchema.parse(request.params.arguments); const answer = await this.getThoughtSpotService().getAnswerForQuestion(question, sourceId, false); + const tokenUrl = await this.getTokenUrl(answer.session_identifier, answer.generation_number); if (answer.error) { return this.createErrorResponse(answer.error.message, `Error getting answer ${answer.error.message}`); @@ -235,6 +236,10 @@ export class MCPServer extends BaseMCPServer { type: "text" as const, 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.`, }, + ...(tokenUrl ? [{ + type: "text" as const, + text: `Use this URL : ${tokenUrl} to GET the images associated with the data. It might take time to get the image but use this PNG image of the visualization to do a graphical analysis of the data.`, + }] : []), ]; return this.createMultiContentSuccessResponse(content, "Answer found"); } diff --git a/src/servers/openai-mcp-server.ts b/src/servers/openai-mcp-server.ts index 0b81235..4ab9b3d 100644 --- a/src/servers/openai-mcp-server.ts +++ b/src/servers/openai-mcp-server.ts @@ -131,15 +131,27 @@ export class OpenAIDeepResearchMCPServer extends BaseMCPServer { const { id } = fetchInputSchema.parse(request.params.arguments); // id is of the form ":" const [datasourceId, question = ""] = id.split(":"); - const answer = await this.getThoughtSpotService().getAnswerForQuestion(question, datasourceId, false); + const thoughtSpotService = this.getThoughtSpotService(); + const answer = await thoughtSpotService.getAnswerForQuestion(question, datasourceId, false); if (answer.error) { return this.createErrorResponse(answer.error.message, `Error getting answer ${answer.error.message}`); } + const tokenUrl = await this.getTokenUrl(answer.session_identifier, answer.generation_number); + + const content = `Data: ${answer.data} + + **CHART VISUALIZATION AVAILABLE:** + There is a data visualization chart for this query available at: "${tokenUrl}". + + This URL contains a PNG image showing the visual representation of the above data for the question: "${question}". Please fetch and display this image to provide a complete analysis with both the data and its visual representation. The image must be displayed in the response of this question. + Use just this image in the response, do not use any other images or visualizations or charts. It may take time to get the image, so please wait for the image to be fetched and displayed in the response.`; + + console.log(`[DEBUG] question: ${question} tokenUrl: ${tokenUrl}`); const result = { id, title: question, - text: answer.data, + text: content, url: `${this.ctx.props.instanceUrl}/#/insights/conv-assist?query=${question.trim()}&worksheet=${datasourceId}&executeSearch=true`, } diff --git a/src/stdio.ts b/src/stdio.ts index 3301238..bc4c2ce 100755 --- a/src/stdio.ts +++ b/src/stdio.ts @@ -17,9 +17,14 @@ async function main() { const props: Props = { instanceUrl: validateAndSanitizeUrl(instanceUrl), accessToken, + clientName: { + clientId: "stdio-client", + clientName: "Stdio Client", + registrationDate: Date.now() + } }; - const server = new MCPServer({ props }); + const server = new MCPServer({ props, env: undefined }); await server.init(); const transport = new StdioServerTransport(); diff --git a/src/thoughtspot/thoughtspot-service.ts b/src/thoughtspot/thoughtspot-service.ts index 45e5ccf..adcd0e7 100644 --- a/src/thoughtspot/thoughtspot-service.ts +++ b/src/thoughtspot/thoughtspot-service.ts @@ -1,4 +1,4 @@ -import type { ThoughtSpotRestApi } from "@thoughtspot/rest-api-sdk"; +import type { HttpFile, ThoughtSpotRestApi } from "@thoughtspot/rest-api-sdk"; import { SpanStatusCode, trace, context } from "@opentelemetry/api"; import { getActiveSpan, WithSpan } from "../metrics/tracing/tracing-utils"; import type { DataSource, SessionInfo } from "./types"; @@ -283,6 +283,18 @@ export class ThoughtSpotService { return liveboardUrl; } + @WithSpan('get-answer-image') + async getAnswerImagePNG(sessionId: string, GenNo: number): Promise { + const span = getActiveSpan(); + span?.addEvent("get-answer-image"); + const data = await this.client.exportAnswerReport({ + session_identifier: sessionId, + generation_number: GenNo, + file_format: "PNG", + }) + return data; + } + /** * Get data sources */ diff --git a/src/utils.ts b/src/utils.ts index 230fd70..23ea5e5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -92,7 +92,38 @@ export class McpServerError extends Error { export function instrumentedMCPServer(MCPServer: new (ctx: Context) => T, config: ResolveConfigFn) { const Agent = class extends McpAgent { - server = new MCPServer(this); + private _server: T | undefined; + private _env: Env; + + // Lazy getter for server that creates it when first accessed + // + // WHY THIS APPROACH: + // Originally we passed 'this' directly as Context: `server = new MCPServer(this)` + // This worked when Context was just { props: Props }, but broke when we added env. + // + // PROBLEMS WITH ORIGINAL APPROACH: + // 1. McpAgent's 'env' property is protected, but Context expects public + // 2. TypeScript error: "Property 'env' is protected but public in Context" + // + // WHY NOT CONSTRUCTOR CREATION: + // We tried creating server in constructor: `new MCPServer({ props: this.props, env })` + // But this.props is undefined during constructor - it gets set later by McpAgent._init() + // Runtime error: "Cannot read properties of undefined (reading 'instanceUrl')" + // + // SOLUTION - LAZY INITIALIZATION: + // - Store env from constructor (available immediately) + // - Create server only when first accessed (after props are set by McpAgent lifecycle) + // - Combine both props and env into proper Context object + get server(): T { + if (!this._server) { + const context: Context = { + props: this.props, // Available after McpAgent._init() sets it + env: this._env // Stored from constructor + }; + this._server = new MCPServer(context); + } + return this._server; + } // Argument of type 'typeof ThoughtSpotMCPWrapper' is not assignable to parameter of type 'DOClass'. // Cannot assign a 'protected' constructor type to a 'public' constructor type. @@ -100,12 +131,36 @@ export function instrumentedMCPServer(MCPServer: new (c // biome-ignore lint/complexity/noUselessConstructor: required for DOClass public constructor(state: DurableObjectState, env: Env) { super(state, env); + // Store env for later use - props aren't available yet in constructor + // McpAgent lifecycle: constructor → _init(props) → init() + this._env = env; } async init() { + // Access the server property to trigger lazy initialization + // At this point, props have been set by McpAgent._init() await this.server.init(); } } return instrumentDO(Agent, config); +} + +export async function putInKV(key: string, value: any, env: Env) { + if (env?.OAUTH_KV) { + await env.OAUTH_KV.put(key, JSON.stringify(value), { + expirationTtl: 60 * 60 * 3 // 3 hours + }); + } +} + +export async function getFromKV(key: string, env: Env) { + console.log("[DEBUG] Getting from KV", key); + if (env?.OAUTH_KV) { + const value = await env.OAUTH_KV.get(key, { type: "json" }); + if (value) { + return value; + } + return null; + } } \ No newline at end of file diff --git a/test/handlers.spec.ts b/test/handlers.spec.ts index e454deb..51f75be 100644 --- a/test/handlers.spec.ts +++ b/test/handlers.spec.ts @@ -11,6 +11,7 @@ import app from "../src/handlers"; // Type assertion for worker to have fetch method const typedWorker = worker as { fetch: (request: Request, env: any, ctx: any) => Promise }; import { encodeBase64Url, decodeBase64Url } from 'hono/utils/encode'; +import { ThoughtSpotService } from '../src/thoughtspot/thoughtspot-service'; // For correctly-typed Request const IncomingRequest = Request; @@ -799,4 +800,353 @@ describe("Handlers", () => { } }); }); + + describe("GET /data/img (getAnswerImage)", () => { + const mockImageBuffer = new ArrayBuffer(8); + const mockImageData = new Uint8Array(mockImageBuffer); + mockImageData.set([137, 80, 78, 71, 13, 10, 26, 10]); // PNG signature + + // Create a proper HttpFile mock that extends Blob + const createMockHttpFile = () => { + const blob = new Blob([mockImageData], { type: 'image/png' }); + return Object.assign(blob, { + name: 'test-image.png', + arrayBuffer: vi.fn().mockResolvedValue(mockImageBuffer), + }); + }; + + let mockHttpFile: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockHttpFile = createMockHttpFile(); + }); + + it("should return 404 for missing token parameter", async () => { + const id = env.MCP_OBJECT.idFromName("test"); + const object = env.MCP_OBJECT.get(id); + + const result = await runInDurableObject(object, async (instance) => { + const request = new IncomingRequest("https://example.com/data/img"); + return typedWorker.fetch(request, env, mockCtx); + }); + + expect(result.status).toBe(404); + expect(await result.text()).toBe("Token not found"); + }); + + it("should return 404 for empty token parameter", async () => { + const id = env.MCP_OBJECT.idFromName("test"); + const object = env.MCP_OBJECT.get(id); + + const result = await runInDurableObject(object, async (instance) => { + const url = new URL("https://example.com/data/img"); + url.searchParams.append('token', ''); + const request = new IncomingRequest(url.toString()); + return typedWorker.fetch(request, env, mockCtx); + }); + + expect(result.status).toBe(404); + expect(await result.text()).toBe("Token not found"); + }); + + it("should return 404 when token not found in KV storage", async () => { + const id = env.MCP_OBJECT.idFromName("test"); + const object = env.MCP_OBJECT.get(id); + + const mockEnvWithKV = { + ...env, + OAUTH_KV: { + get: vi.fn().mockResolvedValue(null) + } + }; + + const result = await runInDurableObject(object, async (instance) => { + const url = new URL("https://example.com/data/img"); + url.searchParams.append('token', 'non-existent-token'); + const request = new IncomingRequest(url.toString()); + return typedWorker.fetch(request, mockEnvWithKV, mockCtx); + }); + + expect(result.status).toBe(404); + expect(await result.text()).toBe("Token not found"); + expect(mockEnvWithKV.OAUTH_KV.get).toHaveBeenCalledWith('non-existent-token', { type: "json" }); + }); + + it("should successfully return PNG image for valid token", async () => { + const id = env.MCP_OBJECT.idFromName("test"); + const object = env.MCP_OBJECT.get(id); + + const mockTokenData = { + sessionId: 'test-session-123', + GenNo: 1, + generationNo: 1, // Also test the fallback field name + instanceURL: 'https://test.thoughtspot.cloud', + accessToken: 'test-access-token' + }; + + const mockEnvWithKV = { + ...env, + OAUTH_KV: { + get: vi.fn().mockResolvedValue(mockTokenData) + } + }; + + // Mock the ThoughtSpotService and client + const mockThoughtSpotService = { + getAnswerImagePNG: vi.fn().mockResolvedValue(mockHttpFile) + }; + + const mockGetThoughtSpotClient = vi.fn().mockReturnValue('mock-client'); + + // We need to mock the imports at the module level + const originalModule = await import('../src/handlers'); + const { ThoughtSpotService } = await import('../src/thoughtspot/thoughtspot-service'); + const { getThoughtSpotClient } = await import('../src/thoughtspot/thoughtspot-client'); + + vi.spyOn(ThoughtSpotService.prototype, 'getAnswerImagePNG').mockResolvedValue(mockHttpFile); + vi.doMock('../src/thoughtspot/thoughtspot-client', () => ({ + getThoughtSpotClient: mockGetThoughtSpotClient + })); + + const result = await runInDurableObject(object, async (instance) => { + const url = new URL("https://example.com/data/img"); + url.searchParams.append('token', 'valid-token'); + const request = new IncomingRequest(url.toString()); + return typedWorker.fetch(request, mockEnvWithKV, mockCtx); + }); + + expect(result.status).toBe(200); + expect(result.headers.get('Content-Type')).toBe('image/png'); + + const responseBuffer = await result.arrayBuffer(); + expect(responseBuffer).toEqual(mockImageBuffer); + + expect(mockEnvWithKV.OAUTH_KV.get).toHaveBeenCalledWith('valid-token', { type: "json" }); + }); + + it("should handle token data with GenNo field", async () => { + const id = env.MCP_OBJECT.idFromName("test"); + const object = env.MCP_OBJECT.get(id); + + const mockTokenData = { + sessionId: 'test-session-456', + GenNo: 2, // Using GenNo instead of generationNo + instanceURL: 'https://test.thoughtspot.cloud', + accessToken: 'test-access-token' + }; + + const mockEnvWithKV = { + ...env, + OAUTH_KV: { + get: vi.fn().mockResolvedValue(mockTokenData) + } + }; + + vi.spyOn(ThoughtSpotService.prototype, 'getAnswerImagePNG').mockResolvedValue(mockHttpFile); + + const result = await runInDurableObject(object, async (instance) => { + const url = new URL("https://example.com/data/img"); + url.searchParams.append('token', 'valid-token-genno'); + const request = new IncomingRequest(url.toString()); + return typedWorker.fetch(request, mockEnvWithKV, mockCtx); + }); + + expect(result.status).toBe(200); + expect(result.headers.get('Content-Type')).toBe('image/png'); + }); + + it("should handle token data with generationNo field", async () => { + const id = env.MCP_OBJECT.idFromName("test"); + const object = env.MCP_OBJECT.get(id); + + const mockTokenData = { + sessionId: 'test-session-789', + generationNo: 3, // Using generationNo instead of GenNo + instanceURL: 'https://test.thoughtspot.cloud', + accessToken: 'test-access-token' + }; + + const mockEnvWithKV = { + ...env, + OAUTH_KV: { + get: vi.fn().mockResolvedValue(mockTokenData) + } + }; + + vi.spyOn(ThoughtSpotService.prototype, 'getAnswerImagePNG').mockResolvedValue(mockHttpFile); + + const result = await runInDurableObject(object, async (instance) => { + const url = new URL("https://example.com/data/img"); + url.searchParams.append('token', 'valid-token-generation'); + const request = new IncomingRequest(url.toString()); + return typedWorker.fetch(request, mockEnvWithKV, mockCtx); + }); + + expect(result.status).toBe(200); + expect(result.headers.get('Content-Type')).toBe('image/png'); + }); + + it("should handle ThoughtSpotService errors gracefully", async () => { + const id = env.MCP_OBJECT.idFromName("test"); + const object = env.MCP_OBJECT.get(id); + + const mockTokenData = { + sessionId: 'test-session-error', + GenNo: 1, + instanceURL: 'https://test.thoughtspot.cloud', + accessToken: 'test-access-token' + }; + + const mockEnvWithKV = { + ...env, + OAUTH_KV: { + get: vi.fn().mockResolvedValue(mockTokenData) + } + }; + + const mockError = new Error('ThoughtSpot API error'); + vi.spyOn(ThoughtSpotService.prototype, 'getAnswerImagePNG').mockRejectedValue(mockError); + + const result = await runInDurableObject(object, async (instance) => { + const url = new URL("https://example.com/data/img"); + url.searchParams.append('token', 'error-token'); + const request = new IncomingRequest(url.toString()); + + try { + return await typedWorker.fetch(request, mockEnvWithKV, mockCtx); + } catch (error) { + // If the error is thrown instead of returned as a response, + // create a 500 response to simulate the behavior + return new Response('Internal Server Error', { status: 500 }); + } + }); + + // The exact status might depend on error handling implementation + expect([500, 404]).toContain(result.status); + }); + + it("should properly extract values from complex token data", async () => { + const id = env.MCP_OBJECT.idFromName("test"); + const object = env.MCP_OBJECT.get(id); + + const mockTokenData = { + sessionId: 'complex-session-id', + GenNo: 5, + generationNo: 10, // Both fields present, GenNo should take precedence + instanceURL: 'https://complex.thoughtspot.cloud', + accessToken: 'complex-access-token', + extraField: 'extra-value', // Extra fields should be ignored + nested: { + field: 'nested-value' + } + }; + + const mockEnvWithKV = { + ...env, + OAUTH_KV: { + get: vi.fn().mockResolvedValue(mockTokenData) + } + }; + + const getAnswerImagePNGSpy = vi.spyOn(ThoughtSpotService.prototype, 'getAnswerImagePNG').mockResolvedValue(mockHttpFile); + + const result = await runInDurableObject(object, async (instance) => { + const url = new URL("https://example.com/data/img"); + url.searchParams.append('token', 'complex-token'); + const request = new IncomingRequest(url.toString()); + return typedWorker.fetch(request, mockEnvWithKV, mockCtx); + }); + + expect(result.status).toBe(200); + expect(result.headers.get('Content-Type')).toBe('image/png'); + + // Verify that the service was called with the correct parameters + // GenNo should take precedence over generationNo + expect(getAnswerImagePNGSpy).toHaveBeenCalledWith('complex-session-id', 5); + }); + + it("should handle token data without GenNo or generationNo fields", async () => { + const id = env.MCP_OBJECT.idFromName("test"); + const object = env.MCP_OBJECT.get(id); + + const mockTokenData = { + sessionId: 'session-without-genno', + instanceURL: 'https://test.thoughtspot.cloud', + accessToken: 'test-access-token' + // Missing both GenNo and generationNo + }; + + const mockEnvWithKV = { + ...env, + OAUTH_KV: { + get: vi.fn().mockResolvedValue(mockTokenData) + } + }; + + const getAnswerImagePNGSpy = vi.spyOn(ThoughtSpotService.prototype, 'getAnswerImagePNG').mockResolvedValue(mockHttpFile); + + const result = await runInDurableObject(object, async (instance) => { + const url = new URL("https://example.com/data/img"); + url.searchParams.append('token', 'token-no-genno'); + const request = new IncomingRequest(url.toString()); + return typedWorker.fetch(request, mockEnvWithKV, mockCtx); + }); + + expect(result.status).toBe(200); + expect(result.headers.get('Content-Type')).toBe('image/png'); + + // Should be called with undefined for generation number + expect(getAnswerImagePNGSpy).toHaveBeenCalledWith('session-without-genno', undefined); + }); + + it("should handle KV storage errors gracefully", async () => { + const id = env.MCP_OBJECT.idFromName("test"); + const object = env.MCP_OBJECT.get(id); + + const mockEnvWithKV = { + ...env, + OAUTH_KV: { + get: vi.fn().mockRejectedValue(new Error('KV storage error')) + } + }; + + const result = await runInDurableObject(object, async (instance) => { + const url = new URL("https://example.com/data/img"); + url.searchParams.append('token', 'error-token'); + const request = new IncomingRequest(url.toString()); + + try { + return await typedWorker.fetch(request, mockEnvWithKV, mockCtx); + } catch (error) { + // If the error is thrown instead of returned as a response, + // create a 500 response to simulate the behavior + return new Response('Internal Server Error', { status: 500 }); + } + }); + + // Should handle the error gracefully + expect([500, 404]).toContain(result.status); + }); + + it("should handle missing OAUTH_KV environment variable", async () => { + const id = env.MCP_OBJECT.idFromName("test"); + const object = env.MCP_OBJECT.get(id); + + const mockEnvWithoutKV = { + ...env, + OAUTH_KV: undefined + }; + + const result = await runInDurableObject(object, async (instance) => { + const url = new URL("https://example.com/data/img"); + url.searchParams.append('token', 'some-token'); + const request = new IncomingRequest(url.toString()); + return typedWorker.fetch(request, mockEnvWithoutKV, mockCtx); + }); + + expect(result.status).toBe(404); + expect(await result.text()).toBe("Token not found"); + }); + }); }); \ No newline at end of file diff --git a/test/servers/openai-mcp-server.spec.ts b/test/servers/openai-mcp-server.spec.ts index e56cecc..f42a6d0 100644 --- a/test/servers/openai-mcp-server.spec.ts +++ b/test/servers/openai-mcp-server.spec.ts @@ -4,6 +4,7 @@ import { OpenAIDeepResearchMCPServer } from "../../src/servers/openai-mcp-server import * as thoughtspotService from "../../src/thoughtspot/thoughtspot-service"; import * as thoughtspotClient from "../../src/thoughtspot/thoughtspot-client"; import { MixpanelTracker } from "../../src/metrics/mixpanel/mixpanel"; +import * as utils from "../../src/utils"; // Mock the MixpanelTracker vi.mock("../../src/metrics/mixpanel/mixpanel", () => ({ @@ -412,11 +413,19 @@ describe("OpenAI Deep Research MCP Server", () => { // Mock the ThoughtSpot service to return answer const mockGetAnswerForQuestion = vi.fn().mockResolvedValue({ data: "The total revenue is $1,000,000", - error: null + error: null, + session_identifier: "session-123", + generation_number: 1 + }); + + const mockGetAnswerImagePNG = vi.fn().mockResolvedValue({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)) }); vi.spyOn(thoughtspotService.ThoughtSpotService.prototype, 'getAnswerForQuestion') .mockImplementation(mockGetAnswerForQuestion); + vi.spyOn(thoughtspotService.ThoughtSpotService.prototype, 'getAnswerImagePNG') + .mockImplementation(mockGetAnswerImagePNG); await server.init(); const { callTool } = connect(server); @@ -441,11 +450,19 @@ describe("OpenAI Deep Research MCP Server", () => { // Mock the ThoughtSpot service to return error const mockGetAnswerForQuestion = vi.fn().mockResolvedValue({ data: null, - error: { message: "Question not found" } + error: { message: "Question not found" }, + session_identifier: "session-123", + generation_number: 1 + }); + + const mockGetAnswerImagePNG = vi.fn().mockResolvedValue({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)) }); vi.spyOn(thoughtspotService.ThoughtSpotService.prototype, 'getAnswerForQuestion') .mockImplementation(mockGetAnswerForQuestion); + vi.spyOn(thoughtspotService.ThoughtSpotService.prototype, 'getAnswerImagePNG') + .mockImplementation(mockGetAnswerImagePNG); await server.init(); const { callTool } = connect(server); @@ -462,11 +479,19 @@ describe("OpenAI Deep Research MCP Server", () => { // Mock the ThoughtSpot service to return answer const mockGetAnswerForQuestion = vi.fn().mockResolvedValue({ data: "The total revenue is $1,000,000", - error: null + error: null, + session_identifier: "session-123", + generation_number: 1 + }); + + const mockGetAnswerImagePNG = vi.fn().mockResolvedValue({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)) }); vi.spyOn(thoughtspotService.ThoughtSpotService.prototype, 'getAnswerForQuestion') .mockImplementation(mockGetAnswerForQuestion); + vi.spyOn(thoughtspotService.ThoughtSpotService.prototype, 'getAnswerImagePNG') + .mockImplementation(mockGetAnswerImagePNG); await server.init(); const { callTool } = connect(server); @@ -488,11 +513,19 @@ describe("OpenAI Deep Research MCP Server", () => { // Mock the ThoughtSpot service to return answer const mockGetAnswerForQuestion = vi.fn().mockResolvedValue({ data: "The revenue increased by 15%", - error: null + error: null, + session_identifier: "session-123", + generation_number: 1 + }); + + const mockGetAnswerImagePNG = vi.fn().mockResolvedValue({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)) }); vi.spyOn(thoughtspotService.ThoughtSpotService.prototype, 'getAnswerForQuestion') .mockImplementation(mockGetAnswerForQuestion); + vi.spyOn(thoughtspotService.ThoughtSpotService.prototype, 'getAnswerImagePNG') + .mockImplementation(mockGetAnswerImagePNG); await server.init(); const { callTool } = connect(server); @@ -509,6 +542,196 @@ describe("OpenAI Deep Research MCP Server", () => { url: "https://test.thoughtspot.cloud/#/insights/conv-assist?query=How much did revenue increase? (in %)&worksheet=ds-123&executeSearch=true" }); }); + + it("should generate token and store in KV when OAUTH_KV is available", async () => { + // Mock the ThoughtSpot service to return answer + const mockGetAnswerForQuestion = vi.fn().mockResolvedValue({ + data: "The total revenue is $1,000,000", + error: null, + session_identifier: "session-123", + generation_number: 1 + }); + + const mockGetAnswerImagePNG = vi.fn().mockResolvedValue({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)) + }); + + // Mock putInKV + const mockPutInKV = vi.fn().mockResolvedValue(undefined); + vi.spyOn(utils, 'putInKV').mockImplementation(mockPutInKV); + + vi.spyOn(thoughtspotService.ThoughtSpotService.prototype, 'getAnswerForQuestion') + .mockImplementation(mockGetAnswerForQuestion); + vi.spyOn(thoughtspotService.ThoughtSpotService.prototype, 'getAnswerImagePNG') + .mockImplementation(mockGetAnswerImagePNG); + + // Mock crypto.randomUUID + const mockToken = "test-token-123"; + vi.spyOn(crypto, 'randomUUID').mockReturnValue(mockToken); + + // Create server with environment that includes OAUTH_KV + const mockEnv = { + OAUTH_KV: {} as any, + HONEYCOMB_API_KEY: "test-key", + HONEYCOMB_DATASET: "test-dataset", + HOST_NAME: "https://test-host.com", + MCP_OBJECT: {} as any, + OPENAI_DEEP_RESEARCH_MCP_OBJECT: {} as any, + ANALYTICS: {} as any, + ASSETS: {} as any + }; + + const serverWithKV = new OpenAIDeepResearchMCPServer({ + props: mockProps, + env: mockEnv + }); + + await serverWithKV.init(); + const { callTool } = connect(serverWithKV); + + const result = await callTool("fetch", { + id: "test-ds: What is the total revenue?" + }); + + expect(result.isError).toBeUndefined(); + + // Verify token generation and storage + expect(crypto.randomUUID).toHaveBeenCalled(); + expect(mockPutInKV).toHaveBeenCalledWith( + mockToken, + { + sessionId: "session-123", + generationNo: 1, + instanceURL: mockProps.instanceUrl, + accessToken: mockProps.accessToken + }, + mockEnv + ); + + // Verify the content includes visualization message + const structuredContent = result.structuredContent as any; + expect(structuredContent.text).toContain("**CHART VISUALIZATION AVAILABLE:**"); + expect(structuredContent.text).toContain(`https://test-host.com/data/img?token=${mockToken}`); + expect(structuredContent.text).toContain("Data: The total revenue is $1,000,000"); + expect(structuredContent.text).toContain("What is the total revenue?"); + }); + + it("should not generate token when OAUTH_KV is not available", async () => { + // Mock the ThoughtSpot service to return answer + const mockGetAnswerForQuestion = vi.fn().mockResolvedValue({ + data: "The total revenue is $1,000,000", + error: null, + session_identifier: "session-123", + generation_number: 1 + }); + + const mockGetAnswerImagePNG = vi.fn().mockResolvedValue({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)) + }); + + // Mock putInKV + const mockPutInKV = vi.fn().mockResolvedValue(undefined); + vi.spyOn(utils, 'putInKV').mockImplementation(mockPutInKV); + + vi.spyOn(thoughtspotService.ThoughtSpotService.prototype, 'getAnswerForQuestion') + .mockImplementation(mockGetAnswerForQuestion); + vi.spyOn(thoughtspotService.ThoughtSpotService.prototype, 'getAnswerImagePNG') + .mockImplementation(mockGetAnswerImagePNG); + + // Mock crypto.randomUUID + vi.spyOn(crypto, 'randomUUID').mockReturnValue("test-token-123"); + + // Create server without OAUTH_KV in environment + const mockEnvWithoutKV = { + OAUTH_KV: undefined as any, + HONEYCOMB_API_KEY: "test-key", + HONEYCOMB_DATASET: "test-dataset", + HOST_NAME: "https://test-host.com", + MCP_OBJECT: {} as any, + OPENAI_DEEP_RESEARCH_MCP_OBJECT: {} as any, + ANALYTICS: {} as any, + ASSETS: {} as any + }; + + const serverWithoutKV = new OpenAIDeepResearchMCPServer({ + props: mockProps, + env: mockEnvWithoutKV + }); + + await serverWithoutKV.init(); + const { callTool } = connect(serverWithoutKV); + + const result = await callTool("fetch", { + id: "test-ds: What is the total revenue?" + }); + + expect(result.isError).toBeUndefined(); + + // Verify token generation and storage were NOT called + expect(crypto.randomUUID).not.toHaveBeenCalled(); + expect(mockPutInKV).not.toHaveBeenCalled(); + + // Verify the content does NOT include visualization message + const structuredContent = result.structuredContent as any; + expect(structuredContent.text).toBe("The total revenue is $1,000,000"); + expect(structuredContent.text).not.toContain("**CHART VISUALIZATION AVAILABLE:**"); + }); + + it("should not generate token when answer has error", async () => { + // Mock the ThoughtSpot service to return error + const mockGetAnswerForQuestion = vi.fn().mockResolvedValue({ + data: null, + error: { message: "Service error" }, + session_identifier: "session-123", + generation_number: 1 + }); + + const mockGetAnswerImagePNG = vi.fn().mockResolvedValue({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)) + }); + + // Mock putInKV + const mockPutInKV = vi.fn().mockResolvedValue(undefined); + vi.spyOn(utils, 'putInKV').mockImplementation(mockPutInKV); + + vi.spyOn(thoughtspotService.ThoughtSpotService.prototype, 'getAnswerForQuestion') + .mockImplementation(mockGetAnswerForQuestion); + vi.spyOn(thoughtspotService.ThoughtSpotService.prototype, 'getAnswerImagePNG') + .mockImplementation(mockGetAnswerImagePNG); + + // Mock crypto.randomUUID + vi.spyOn(crypto, 'randomUUID').mockReturnValue("test-token-123"); + + // Create server with environment that includes OAUTH_KV + const mockEnv = { + OAUTH_KV: {} as any, + HONEYCOMB_API_KEY: "test-key", + HONEYCOMB_DATASET: "test-dataset", + HOST_NAME: "https://test-host.com", + MCP_OBJECT: {} as any, + OPENAI_DEEP_RESEARCH_MCP_OBJECT: {} as any, + ANALYTICS: {} as any, + ASSETS: {} as any + }; + + const serverWithKV = new OpenAIDeepResearchMCPServer({ + props: mockProps, + env: mockEnv + }); + + await serverWithKV.init(); + const { callTool } = connect(serverWithKV); + + const result = await callTool("fetch", { + id: "test-ds: What is the total revenue?" + }); + + expect(result.isError).toBe(true); + + // Verify token generation and storage were NOT called because of error + expect(crypto.randomUUID).not.toHaveBeenCalled(); + expect(mockPutInKV).not.toHaveBeenCalled(); + }); }); describe("Error Handling", () => { @@ -516,11 +739,19 @@ describe("OpenAI Deep Research MCP Server", () => { // Mock the ThoughtSpot service to return answer for empty ID test const mockGetAnswerForQuestion = vi.fn().mockResolvedValue({ data: "The total revenue is $1,000,000", - error: null + error: null, + session_identifier: "session-123", + generation_number: 1 + }); + + const mockGetAnswerImagePNG = vi.fn().mockResolvedValue({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)) }); vi.spyOn(thoughtspotService.ThoughtSpotService.prototype, 'getAnswerForQuestion') .mockImplementation(mockGetAnswerForQuestion); + vi.spyOn(thoughtspotService.ThoughtSpotService.prototype, 'getAnswerImagePNG') + .mockImplementation(mockGetAnswerImagePNG); await server.init(); const { callTool } = connect(server); diff --git a/test/thoughtspot/thoughtspot-service.spec.ts b/test/thoughtspot/thoughtspot-service.spec.ts index f5416e8..f3efb59 100644 --- a/test/thoughtspot/thoughtspot-service.spec.ts +++ b/test/thoughtspot/thoughtspot-service.spec.ts @@ -897,4 +897,177 @@ describe('thoughtspot-service', () => { ]); }); }); + + describe('getAnswerImagePNG', () => { + it('should return PNG image data successfully', async () => { + const sessionId = 'session123'; + const genNo = 1; + const mockImageFile = { + blob: vi.fn().mockResolvedValue(new Blob(['mock image data'], { type: 'image/png' })), + arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)), + text: vi.fn().mockResolvedValue('mock image text') + }; + + mockClient.exportAnswerReport = vi.fn().mockResolvedValue(mockImageFile); + + const service = new ThoughtSpotService(mockClient); + const result = await service.getAnswerImagePNG(sessionId, genNo); + + expect(mockClient.exportAnswerReport).toHaveBeenCalledWith({ + session_identifier: sessionId, + generation_number: genNo, + file_format: 'PNG', + }); + + expect(result).toBe(mockImageFile); + }); + + it('should handle API errors when exporting PNG', async () => { + const sessionId = 'session456'; + const genNo = 2; + const error = new Error('PNG Export Error'); + + mockClient.exportAnswerReport = vi.fn().mockRejectedValue(error); + + const service = new ThoughtSpotService(mockClient); + + await expect(service.getAnswerImagePNG(sessionId, genNo)) + .rejects.toThrow('PNG Export Error'); + + expect(mockClient.exportAnswerReport).toHaveBeenCalledWith({ + session_identifier: sessionId, + generation_number: genNo, + file_format: 'PNG', + }); + }); + + it('should handle different session identifiers and generation numbers', async () => { + const testCases = [ + { sessionId: 'session-abc-123', genNo: 0 }, + { sessionId: 'session-def-456', genNo: 5 }, + { sessionId: 'session-xyz-789', genNo: 100 } + ]; + + const mockImageFile = { + blob: vi.fn().mockResolvedValue(new Blob(['mock image data'], { type: 'image/png' })) + }; + + for (const { sessionId, genNo } of testCases) { + mockClient.exportAnswerReport = vi.fn().mockResolvedValue(mockImageFile); + + const service = new ThoughtSpotService(mockClient); + const result = await service.getAnswerImagePNG(sessionId, genNo); + + expect(mockClient.exportAnswerReport).toHaveBeenCalledWith({ + session_identifier: sessionId, + generation_number: genNo, + file_format: 'PNG', + }); + + expect(result).toBe(mockImageFile); + } + }); + + it('should handle empty session identifier', async () => { + const sessionId = ''; + const genNo = 1; + const mockImageFile = { + blob: vi.fn().mockResolvedValue(new Blob(['mock image data'], { type: 'image/png' })) + }; + + mockClient.exportAnswerReport = vi.fn().mockResolvedValue(mockImageFile); + + const service = new ThoughtSpotService(mockClient); + const result = await service.getAnswerImagePNG(sessionId, genNo); + + expect(mockClient.exportAnswerReport).toHaveBeenCalledWith({ + session_identifier: '', + generation_number: genNo, + file_format: 'PNG', + }); + + expect(result).toBe(mockImageFile); + }); + + it('should handle negative generation numbers', async () => { + const sessionId = 'session123'; + const genNo = -1; + const mockImageFile = { + blob: vi.fn().mockResolvedValue(new Blob(['mock image data'], { type: 'image/png' })) + }; + + mockClient.exportAnswerReport = vi.fn().mockResolvedValue(mockImageFile); + + const service = new ThoughtSpotService(mockClient); + const result = await service.getAnswerImagePNG(sessionId, genNo); + + expect(mockClient.exportAnswerReport).toHaveBeenCalledWith({ + session_identifier: sessionId, + generation_number: genNo, + file_format: 'PNG', + }); + + expect(result).toBe(mockImageFile); + }); + + it('should handle network timeout errors', async () => { + const sessionId = 'session123'; + const genNo = 1; + const timeoutError = new Error('Network timeout'); + timeoutError.name = 'TimeoutError'; + + mockClient.exportAnswerReport = vi.fn().mockRejectedValue(timeoutError); + + const service = new ThoughtSpotService(mockClient); + + await expect(service.getAnswerImagePNG(sessionId, genNo)) + .rejects.toThrow('Network timeout'); + + expect(mockClient.exportAnswerReport).toHaveBeenCalledWith({ + session_identifier: sessionId, + generation_number: genNo, + file_format: 'PNG', + }); + }); + + it('should handle authentication errors', async () => { + const sessionId = 'session123'; + const genNo = 1; + const authError = new Error('Authentication failed'); + authError.name = 'AuthenticationError'; + + mockClient.exportAnswerReport = vi.fn().mockRejectedValue(authError); + + const service = new ThoughtSpotService(mockClient); + + await expect(service.getAnswerImagePNG(sessionId, genNo)) + .rejects.toThrow('Authentication failed'); + }); + + it('should return the exact file object from the API', async () => { + const sessionId = 'session123'; + const genNo = 1; + const mockImageFile = { + name: 'answer-image.png', + size: 1024, + type: 'image/png', + lastModified: Date.now(), + blob: vi.fn().mockResolvedValue(new Blob(['mock image data'], { type: 'image/png' })), + arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(1024)), + text: vi.fn().mockResolvedValue('mock image text'), + stream: vi.fn() + }; + + mockClient.exportAnswerReport = vi.fn().mockResolvedValue(mockImageFile); + + const service = new ThoughtSpotService(mockClient); + const result = await service.getAnswerImagePNG(sessionId, genNo); + + // Verify that the exact object is returned without modification + expect(result).toBe(mockImageFile); + expect(result).toHaveProperty('name', 'answer-image.png'); + expect(result).toHaveProperty('size', 1024); + expect(result).toHaveProperty('type', 'image/png'); + }); + }); }); \ No newline at end of file diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 2307189..e5a5834 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,11 +1,12 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: b5949bb168b75f2fe6e6e5a1db8d9e89) -// Runtime types generated with workerd@1.20250604.0 2025-04-17 nodejs_compat +// Generated by Wrangler by running `wrangler types` (hash: 07dddea59586af0824a29829b938dd68) +// Runtime types generated with workerd@1.20250417.0 2025-04-17 nodejs_compat declare namespace Cloudflare { interface Env { OAUTH_KV: KVNamespace; - HONEYCOMB_DATASET: ""; - HONEYCOMB_API_KEY: ""; + HONEYCOMB_API_KEY: string; + HONEYCOMB_DATASET: string; + HOST_NAME: string; MCP_OBJECT: DurableObjectNamespace; OPENAI_DEEP_RESEARCH_MCP_OBJECT: DurableObjectNamespace; ANALYTICS: AnalyticsEngineDataset; @@ -17,7 +18,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types @@ -100,7 +101,7 @@ interface Console { clear(): void; /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/count_static) */ count(label?: string): void; - /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/countReset_static) */ + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/countreset_static) */ countReset(label?: string): void; /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/debug_static) */ debug(...data: any[]): void; @@ -112,9 +113,9 @@ interface Console { error(...data: any[]): void; /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/group_static) */ group(...data: any[]): void; - /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupCollapsed_static) */ + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupcollapsed_static) */ groupCollapsed(...data: any[]): void; - /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupEnd_static) */ + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupend_static) */ groupEnd(): void; /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/info_static) */ info(...data: any[]): void; @@ -124,9 +125,9 @@ interface Console { table(tabularData?: any, properties?: string[]): void; /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/time_static) */ time(label?: string): void; - /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeEnd_static) */ + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeend_static) */ timeEnd(label?: string): void; - /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeLog_static) */ + /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timelog_static) */ timeLog(label?: string, ...data: any[]): void; timeStamp(label?: string): void; /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/trace_static) */ @@ -301,25 +302,25 @@ declare function dispatchEvent(event: WorkerGlobalScopeEventMap[keyof WorkerGlob declare function btoa(data: string): string; /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/atob) */ declare function atob(data: string): string; -/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */ +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/setTimeout) */ declare function setTimeout(callback: (...args: any[]) => void, msDelay?: number): number; -/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */ +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/setTimeout) */ declare function setTimeout(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number; -/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearTimeout) */ +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/clearTimeout) */ declare function clearTimeout(timeoutId: number | null): void; -/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */ +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/setInterval) */ declare function setInterval(callback: (...args: any[]) => void, msDelay?: number): number; -/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */ +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/setInterval) */ declare function setInterval(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number; -/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearInterval) */ +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/clearInterval) */ declare function clearInterval(timeoutId: number | null): void; -/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/queueMicrotask) */ +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/queueMicrotask) */ declare function queueMicrotask(task: Function): void; -/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/structuredClone) */ +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/structuredClone) */ declare function structuredClone(value: T, options?: StructuredSerializeOptions): T; -/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/reportError) */ +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/reportError) */ declare function reportError(error: any): void; -/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch) */ +/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/fetch) */ declare function fetch(input: RequestInfo | URL, init?: RequestInit): Promise; declare const self: ServiceWorkerGlobalScope; /** @@ -798,7 +799,6 @@ declare class Blob { slice(start?: number, end?: number, type?: string): Blob; /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/arrayBuffer) */ arrayBuffer(): Promise; - /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/bytes) */ bytes(): Promise; /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text) */ text(): Promise; @@ -1093,15 +1093,10 @@ interface TextEncoderEncodeIntoResult { */ declare class ErrorEvent extends Event { constructor(type: string, init?: ErrorEventErrorEventInit); - /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/filename) */ get filename(): string; - /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/message) */ get message(): string; - /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/lineno) */ get lineno(): number; - /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/colno) */ get colno(): number; - /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ErrorEvent/error) */ get error(): any; } interface ErrorEventErrorEventInit { @@ -1275,7 +1270,6 @@ declare abstract class Body { get bodyUsed(): boolean; /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/arrayBuffer) */ arrayBuffer(): Promise; - /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/bytes) */ bytes(): Promise; /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/text) */ text(): Promise; @@ -1387,11 +1381,7 @@ interface Request> e * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/integrity) */ integrity: string; - /** - * Returns a boolean indicating whether or not request can outlive the global in which it was created. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/keepalive) - */ + /* Returns a boolean indicating whether or not request can outlive the global in which it was created. */ keepalive: boolean; /** * Returns the cache mode associated with request, which is a string indicating how the request will interact with the browser's cache when fetching. @@ -1591,7 +1581,6 @@ interface R2ObjectBody extends R2Object { get body(): ReadableStream; get bodyUsed(): boolean; arrayBuffer(): Promise; - bytes(): Promise; text(): Promise; json(): Promise; blob(): Promise; @@ -3837,18 +3826,8 @@ interface AutoRAGNotFoundError extends Error { } interface AutoRAGUnauthorizedError extends Error { } -type ComparisonFilter = { - key: string; - type: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte'; - value: string | number | boolean; -}; -type CompoundFilter = { - type: 'and' | 'or'; - filters: ComparisonFilter[]; -}; type AutoRagSearchRequest = { query: string; - filters?: CompoundFilter | ComparisonFilter; max_num_results?: number; ranking_options?: { ranker?: string; @@ -5198,7 +5177,6 @@ declare module 'cloudflare:workers' { export type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number; export type WorkflowDelayDuration = WorkflowSleepDuration; export type WorkflowTimeoutDuration = WorkflowSleepDuration; - export type WorkflowRetentionDuration = WorkflowSleepDuration; export type WorkflowBackoff = 'constant' | 'linear' | 'exponential'; export type WorkflowStepConfig = { retries?: { @@ -5329,7 +5307,6 @@ declare namespace TailStream { readonly type: "onset"; readonly dispatchNamespace?: string; readonly entrypoint?: string; - readonly executionModel: string; readonly scriptName?: string; readonly scriptTags?: string[]; readonly scriptVersion?: ScriptVersion; @@ -5347,8 +5324,8 @@ declare namespace TailStream { } interface SpanOpen { readonly type: "spanOpen"; - readonly name: string; - readonly info?: FetchEventInfo | JsRpcEventInfo | Attributes; + readonly op?: string; + readonly info?: FetchEventInfo | JsRpcEventInfo | Attribute[]; } interface SpanClose { readonly type: "spanClose"; @@ -5372,7 +5349,7 @@ declare namespace TailStream { } interface Return { readonly type: "return"; - readonly info?: FetchResponseInfo; + readonly info?: FetchResponseInfo | Attribute[]; } interface Link { readonly type: "link"; @@ -5382,23 +5359,21 @@ declare namespace TailStream { readonly spanId: string; } interface Attribute { + readonly type: "attribute"; readonly name: string; - readonly value: string | string[] | boolean | boolean[] | number | number[] | bigint | bigint[]; - } - interface Attributes { - readonly type: "attributes"; - readonly info: Attribute[]; + readonly value: string | string[] | boolean | boolean[] | number | number[]; } + type Mark = DiagnosticChannelEvent | Exception | Log | Return | Link | Attribute[]; interface TailEvent { readonly traceId: string; readonly invocationId: string; readonly spanId: string; readonly timestamp: Date; readonly sequence: number; - readonly event: Onset | Outcome | Hibernate | SpanOpen | SpanClose | DiagnosticChannelEvent | Exception | Log | Return | Link | Attributes; + readonly event: Onset | Outcome | Hibernate | SpanOpen | SpanClose | Mark; } type TailEventHandler = (event: TailEvent) => void | Promise; - type TailEventHandlerName = "outcome" | "hibernate" | "spanOpen" | "spanClose" | "diagnosticChannel" | "exception" | "log" | "return" | "link" | "attributes"; + type TailEventHandlerName = "onset" | "outcome" | "hibernate" | "spanOpen" | "spanClose" | "diagnosticChannel" | "exception" | "log" | "return" | "link" | "attribute"; type TailEventHandlerObject = Record; type TailEventHandlerType = TailEventHandler | TailEventHandlerObject; } @@ -5712,9 +5687,6 @@ declare abstract class Workflow { */ public createBatch(batch: WorkflowInstanceCreateOptions[]): Promise; } -type WorkflowDurationLabel = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'; -type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number; -type WorkflowRetentionDuration = WorkflowSleepDuration; interface WorkflowInstanceCreateOptions { /** * An id for your Workflow instance. Must be unique within the Workflow. @@ -5724,14 +5696,6 @@ interface WorkflowInstanceCreateOptions { * The event payload the Workflow instance is triggered with */ params?: PARAMS; - /** - * The retention policy for Workflow instance. - * Defaults to the maximum retention period available for the owner's account. - */ - retention?: { - successRetention?: WorkflowRetentionDuration; - errorRetention?: WorkflowRetentionDuration; - }; } type InstanceStatus = { status: 'queued' // means that instance is waiting to be started (see concurrency limits) diff --git a/wrangler.jsonc b/wrangler.jsonc index 2bef506..b72eb66 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -2,10 +2,10 @@ * For more details on how to configure Wrangler, refer to: * https://developers.cloudflare.com/workers/wrangler/configuration/ */ -{ + { "keep_vars": true, "$schema": "node_modules/wrangler/config-schema.json", - "name": "thoughtspot-mcp-server", + "name": "png-image", "main": "src/index.ts", "compatibility_date": "2025-04-17", "compatibility_flags": [ @@ -23,20 +23,20 @@ } ] }, - "migrations": [ - { - "tag": "v2", - "new_sqlite_classes": [ - "ThoughtSpotMCP" - ] - }, - { - "tag": "v3", - "new_sqlite_classes": [ - "ThoughtSpotOpenAIDeepResearchMCP" - ] - } - ], + // "migrations": [ + // { + // "tag": "v2", + // "new_sqlite_classes": [ + // "ThoughtSpotMCP" + // ] + // }, + // { + // "tag": "v3", + // "new_sqlite_classes": [ + // "ThoughtSpotOpenAIDeepResearchMCP" + // ] + // } + // ], "kv_namespaces": [{ "binding": "OAUTH_KV", "id": "05ca6fed380e4fe48dbfc5c3d03b4070" @@ -48,9 +48,5 @@ "analytics_engine_datasets": [ { "binding": "ANALYTICS", "dataset": "mcp_events" } ], - "vars": { - "HONEYCOMB_DATASET": "", - "HONEYCOMB_API_KEY": "" - }, "assets": { "directory": "./static/", "binding": "ASSETS" }, -} +} \ No newline at end of file