From 3eeddd62df376cbbe81efaf8759a216e54d3a00e Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Wed, 15 Jul 2026 20:04:41 -0400 Subject: [PATCH 1/9] Record tableau_mcp_event.completed telemetry on MCP app errors Add an app-only server tool (record-mcp-app-error) that the sandboxed MCP app calls via app.callServerTool to record a product-telemetry event through the existing DirectTelemetryForwarder. The app reports all four showError scenarios (TOOL_ERROR, PARSE_ERROR, AUTH_ERROR, EMBED_LOAD_ERROR) via a fire-and-forget client that never throws and no-ops when the host cannot proxy server tools. The tool is app-only (hidden from the model) and gated on the mcp-apps feature flag, mirroring get-embed-token. It makes no Tableau REST API calls; the app supplies scenario (+ optional message) and the server enriches with request/session/site/user/pod context. Telemetry fires before the error-UI early-return so errors are recorded even when the viz container is missing. --- src/server/oauth/scopes.ts | 6 ++ src/server/passthroughAuthMiddleware.test.ts | 3 + .../productTelemetry/telemetryForwarder.ts | 2 +- .../recordMcpAppError.test.ts | 83 +++++++++++++++++++ .../recordMcpAppError/recordMcpAppError.ts | 78 +++++++++++++++++ src/tools/web/toolName.ts | 8 +- src/tools/web/tools.ts | 2 + .../apps/src/lib/handleConfirmResult.test.ts | 6 +- src/web/apps/src/lib/handleConfirmResult.ts | 4 +- src/web/apps/src/lib/handleToolResult.test.ts | 14 ++++ src/web/apps/src/lib/handleToolResult.ts | 10 +-- .../src/lib/recordMcpAppErrorClient.test.ts | 64 ++++++++++++++ .../apps/src/lib/recordMcpAppErrorClient.ts | 35 ++++++++ src/web/apps/src/lib/showError.test.ts | 28 +++++++ src/web/apps/src/lib/showError.ts | 12 ++- 15 files changed, 342 insertions(+), 13 deletions(-) create mode 100644 src/tools/web/recordMcpAppError/recordMcpAppError.test.ts create mode 100644 src/tools/web/recordMcpAppError/recordMcpAppError.ts create mode 100644 src/web/apps/src/lib/recordMcpAppErrorClient.test.ts create mode 100644 src/web/apps/src/lib/recordMcpAppErrorClient.ts diff --git a/src/server/oauth/scopes.ts b/src/server/oauth/scopes.ts index 672e2f88e..a813c730c 100644 --- a/src/server/oauth/scopes.ts +++ b/src/server/oauth/scopes.ts @@ -291,6 +291,11 @@ const toolScopeMap: Record< mcp: [], api: new Set(), }, + // MCP-app error telemetry relay: no Tableau REST API calls, no content scope required. + 'record-mcp-app-error': { + mcp: [], + api: new Set(), + }, // Admin Insights (admin-only). Resolves dataset LUID via list-datasources, then VDS query. // Bypasses resourceAccessChecker — datasources are internal/known and admin-gated. 'query-admin-insights-ts-events': { @@ -395,6 +400,7 @@ function getEnabledToolNames(): Set { // human-gesture confirm steps for their preview tools and only exist when the iframe can render. if (!featureGate.isFeatureEnabled('mcp-apps')) { enabledTools.delete('get-embed-token'); + enabledTools.delete('record-mcp-app-error'); enabledTools.delete('confirm-delete-workbook'); enabledTools.delete('confirm-delete-datasource'); enabledTools.delete('confirm-delete-extract-refresh-task'); diff --git a/src/server/passthroughAuthMiddleware.test.ts b/src/server/passthroughAuthMiddleware.test.ts index 7efc703bd..b51f82053 100644 --- a/src/server/passthroughAuthMiddleware.test.ts +++ b/src/server/passthroughAuthMiddleware.test.ts @@ -21,6 +21,9 @@ const TOOLS_WITHOUT_API_SCOPES_WITH_PASSTHROUGH_GUARD: ReadonlyArray { diff --git a/src/telemetry/productTelemetry/telemetryForwarder.ts b/src/telemetry/productTelemetry/telemetryForwarder.ts index 798016692..b70ab3326 100644 --- a/src/telemetry/productTelemetry/telemetryForwarder.ts +++ b/src/telemetry/productTelemetry/telemetryForwarder.ts @@ -7,7 +7,7 @@ type PropertiesType = { [key: string]: ValidPropertyValueType }; const DEFAULT_HOST_NAME = 'External'; const SERVICE_NAME = 'tableau-mcp'; -export type TelemetryEventType = 'tool_call'; +export type TelemetryEventType = 'tool_call' | 'tableau_mcp_event.completed'; export type ProductTelemetryBase = { endpoint: string; diff --git a/src/tools/web/recordMcpAppError/recordMcpAppError.test.ts b/src/tools/web/recordMcpAppError/recordMcpAppError.test.ts new file mode 100644 index 000000000..3e90c3271 --- /dev/null +++ b/src/tools/web/recordMcpAppError/recordMcpAppError.test.ts @@ -0,0 +1,83 @@ +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { WebMcpServer } from '../../../server.web.js'; +import { Provider } from '../../../utils/provider.js'; +import { getMockRequestHandlerExtra } from '../toolContext.mock.js'; +import { getRecordMcpAppErrorTool } from './recordMcpAppError.js'; + +// Mock getProductTelemetry so we can assert on the forwarder's send(). Note that +// WebTool.logAndExecute also emits an automatic 'tool_call' event through the same +// forwarder, so the spy is called for both 'tool_call' and 'tableau_mcp_event.completed'. +vi.mock('../../../telemetry/productTelemetry/telemetryForwarder.js', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('../../../telemetry/productTelemetry/telemetryForwarder.js') + >(); + return { ...actual, getProductTelemetry: vi.fn() }; +}); + +import { getProductTelemetry } from '../../../telemetry/productTelemetry/telemetryForwarder.js'; + +type Extra = ReturnType; + +describe('getRecordMcpAppErrorTool', () => { + let sendSpy: ReturnType; + + beforeEach(() => { + sendSpy = vi.fn(); + vi.mocked(getProductTelemetry).mockReturnValue({ send: sendSpy } as never); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should create a tool instance with correct properties', async () => { + const tool = getRecordMcpAppErrorTool(new WebMcpServer()); + const annotations = await Provider.from(tool.annotations); + expect(tool.name).toBe('record-mcp-app-error'); + expect(annotations?.readOnlyHint).toBe(true); + expect(annotations?.openWorldHint).toBe(false); + }); + + it('should set visibility to app-only', () => { + const tool = getRecordMcpAppErrorTool(new WebMcpServer()); + expect(tool.meta?.ui?.visibility).toEqual(['app']); + }); + + it('sends an tableau_mcp_event.completed event with the scenario, message and server context', async () => { + const extra = getMockRequestHandlerExtra(); + const result = await getToolResult(extra, { scenario: 'PARSE_ERROR', message: 'bad json' }); + + expect(result.isError).toBe(false); + expect(sendSpy).toHaveBeenCalledWith( + 'tableau_mcp_event.completed', + expect.objectContaining({ + scenario: 'PARSE_ERROR', + message: 'bad json', + podname: extra.config.server, + is_hyperforce: extra.config.isHyperforce, + }), + ); + }); + + it('defaults message to empty string when omitted', async () => { + const extra = getMockRequestHandlerExtra(); + await getToolResult(extra, { scenario: 'EMBED_LOAD_ERROR' }); + + expect(sendSpy).toHaveBeenCalledWith( + 'tableau_mcp_event.completed', + expect.objectContaining({ scenario: 'EMBED_LOAD_ERROR', message: '' }), + ); + }); +}); + +async function getToolResult( + extra: Extra, + args: { scenario: string; message?: string }, +): Promise { + const tool = getRecordMcpAppErrorTool(new WebMcpServer()); + const callback = await Provider.from(tool.callback); + return await callback(args, extra); +} diff --git a/src/tools/web/recordMcpAppError/recordMcpAppError.ts b/src/tools/web/recordMcpAppError/recordMcpAppError.ts new file mode 100644 index 000000000..544d56fa5 --- /dev/null +++ b/src/tools/web/recordMcpAppError/recordMcpAppError.ts @@ -0,0 +1,78 @@ +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { Ok } from 'ts-results-es'; +import { z } from 'zod'; + +import { getFeatureGate } from '../../../features/init.js'; +import { WebMcpServer } from '../../../server.web.js'; +import { getProductTelemetry } from '../../../telemetry/productTelemetry/telemetryForwarder.js'; +import { WebTool } from '../tool.js'; + +// Starting field set — the final app-supplied schema is expected to grow later. +const paramsSchema = { + scenario: z + .string() + .describe( + 'The MCP app error category, e.g. TOOL_ERROR, PARSE_ERROR, AUTH_ERROR, EMBED_LOAD_ERROR.', + ), + message: z.string().optional().describe('Optional detail describing the error cause.'), +}; + +/** + * Records a product-telemetry event when an error occurs in the MCP app UI. + * Called by the app (never the model) via app.callServerTool. Mirrors the + * server-side 'tool_call' telemetry pattern, enriching the event with request + * context the browser bundle does not have. + */ +export const getRecordMcpAppErrorTool = (server: WebMcpServer): WebTool => { + const recordMcpAppErrorTool = new WebTool({ + server, + name: 'record-mcp-app-error', + description: + 'Records a product-telemetry event when an error occurs in the MCP app UI. This tool is only visible to the app, never the model. It takes an error scenario and optional detail, forwards a telemetry event, and returns immediately.', + paramsSchema, + annotations: { + title: 'Record MCP App Error', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + meta: { + ui: { + visibility: ['app'], // Only visible to the app, not the model + }, + }, + disabled: !getFeatureGate().isFeatureEnabled('mcp-apps'), + callback: async (args, extra): Promise => { + return recordMcpAppErrorTool.logAndExecute<{ recorded: true }>({ + extra, + args, + callback: async () => { + const { config, requestId, sessionId } = extra; + + const productTelemetryForwarder = getProductTelemetry( + config.productTelemetryEndpoint, + config.productTelemetryEnabled, + config.server, + ); + + productTelemetryForwarder.send('tableau_mcp_event.completed', { + scenario: args.scenario, + message: args.message ?? '', + request_id: requestId.toString(), + session_id: sessionId ?? '', + site_luid: extra.getSiteLuid(), + user_luid: extra.getUserLuid(), + podname: config.server, + is_hyperforce: config.isHyperforce, + }); + + return Ok({ recorded: true as const }); + }, + constrainSuccessResult: (result) => ({ type: 'success', result }), + }); + }, + }); + + return recordMcpAppErrorTool; +}; diff --git a/src/tools/web/toolName.ts b/src/tools/web/toolName.ts index aba5c505d..38229f008 100644 --- a/src/tools/web/toolName.ts +++ b/src/tools/web/toolName.ts @@ -18,6 +18,7 @@ export const webToolNames = [ 'query-datasource', 'get-datasource-metadata', 'get-embed-token', + 'record-mcp-app-error', 'get-workbook', 'get-view', 'get-view-data', @@ -97,7 +98,12 @@ export const webToolGroups = { ], jobs: ['list-jobs'], users: ['list-users'], - 'token-management': ['get-embed-token', 'revoke-access-token', 'reset-consent'], + 'token-management': [ + 'get-embed-token', + 'record-mcp-app-error', + 'revoke-access-token', + 'reset-consent', + ], 'admin-insights': [ 'query-admin-insights', 'query-admin-insights-ts-events', diff --git a/src/tools/web/tools.ts b/src/tools/web/tools.ts index 9ba38b5c1..cc9fe76af 100644 --- a/src/tools/web/tools.ts +++ b/src/tools/web/tools.ts @@ -25,6 +25,7 @@ import { getListPulseMetricsFromMetricDefinitionIdTool } from './pulse/listMetri import { getListPulseMetricsFromMetricIdsTool } from './pulse/listMetricsFromMetricIds/listPulseMetricsFromMetricIds.js'; import { getListPulseMetricSubscriptionsTool } from './pulse/listMetricSubscriptions/listPulseMetricSubscriptions.js'; import { getQueryDatasourceTool } from './queryDatasource/queryDatasource.js'; +import { getRecordMcpAppErrorTool } from './recordMcpAppError/recordMcpAppError.js'; import { getResetConsentTool } from './resetConsent/resetConsent.js'; import { getRevokeAccessTokenTool } from './revokeAccessToken/revokeAccessToken.js'; import { getListUsersTool } from './users/listUsers.js'; @@ -43,6 +44,7 @@ import { getListWorkbooksTool } from './workbooks/listWorkbooks.js'; export const webToolFactories = [ getGetDatasourceMetadataTool, getEmbedTokenTool, + getRecordMcpAppErrorTool, getListDatasourcesTool, getDeleteDatasourceTool, getConfirmDeleteDatasourceTool, diff --git a/src/web/apps/src/lib/handleConfirmResult.test.ts b/src/web/apps/src/lib/handleConfirmResult.test.ts index c1fb68c5e..5f8ebf4fd 100644 --- a/src/web/apps/src/lib/handleConfirmResult.test.ts +++ b/src/web/apps/src/lib/handleConfirmResult.test.ts @@ -56,7 +56,7 @@ describe('handleConfirmResult', () => { it('shows error UI when tool returns error result (isError: true)', () => { handleConfirmResult(mockApp, { isError: true, content: [{ type: 'text', text: 'boom' }] }); - expect(vi.mocked(showError)).toHaveBeenCalledWith('TOOL_ERROR'); + expect(vi.mocked(showError)).toHaveBeenCalledWith('TOOL_ERROR', undefined, mockApp); expect(vi.mocked(renderDeleteWorkbookConfirm)).not.toHaveBeenCalled(); }); @@ -65,7 +65,7 @@ describe('handleConfirmResult', () => { handleConfirmResult(mockApp, null as any); expect(vi.mocked(showError)).toHaveBeenCalledTimes(2); - expect(vi.mocked(showError)).toHaveBeenCalledWith('TOOL_ERROR'); + expect(vi.mocked(showError)).toHaveBeenCalledWith('TOOL_ERROR', undefined, mockApp); }); it('routes a delete-workbook confirm result to renderDeleteWorkbookConfirm', () => { @@ -113,7 +113,7 @@ describe('handleConfirmResult', () => { it('shows error UI when no known confirm-panel shape matches', () => { handleConfirmResult(mockApp, okResult); - expect(vi.mocked(showError)).toHaveBeenCalledWith('TOOL_ERROR'); + expect(vi.mocked(showError)).toHaveBeenCalledWith('TOOL_ERROR', undefined, mockApp); expect(vi.mocked(renderDeleteWorkbookConfirm)).not.toHaveBeenCalled(); expect(vi.mocked(renderDeleteDatasourceConfirm)).not.toHaveBeenCalled(); expect(vi.mocked(renderDeleteExtractRefreshTaskConfirm)).not.toHaveBeenCalled(); diff --git a/src/web/apps/src/lib/handleConfirmResult.ts b/src/web/apps/src/lib/handleConfirmResult.ts index f24f99a27..49f65b840 100644 --- a/src/web/apps/src/lib/handleConfirmResult.ts +++ b/src/web/apps/src/lib/handleConfirmResult.ts @@ -29,7 +29,7 @@ import { */ export function handleConfirmResult(app: App, result: CallToolResult): void { if (!result || result.isError) { - showError('TOOL_ERROR'); + showError('TOOL_ERROR', undefined, app); return; } @@ -51,5 +51,5 @@ export function handleConfirmResult(app: App, result: CallToolResult): void { } // No known confirm-panel shape matched — surface the error UI rather than silently doing nothing. - showError('TOOL_ERROR'); + showError('TOOL_ERROR', undefined, app); } diff --git a/src/web/apps/src/lib/handleToolResult.test.ts b/src/web/apps/src/lib/handleToolResult.test.ts index d92d459a2..e4012d3d3 100644 --- a/src/web/apps/src/lib/handleToolResult.test.ts +++ b/src/web/apps/src/lib/handleToolResult.test.ts @@ -12,11 +12,13 @@ vi.mock('./getEmbedTokenToolClient.js'); vi.mock('./embedTableauViz.js'); vi.mock('./loadTableauEmbeddingApi.js'); vi.mock('./openInTableauLink.js'); +vi.mock('./recordMcpAppErrorClient.js'); import { embedTableauViz } from './embedTableauViz.js'; import { callGetEmbedTokenTool } from './getEmbedTokenToolClient.js'; import { loadTableauEmbeddingApi } from './loadTableauEmbeddingApi.js'; import { setupOpenInTableauLink } from './openInTableauLink.js'; +import { reportMcpAppError } from './recordMcpAppErrorClient.js'; describe('handleToolResult', () => { let mockApp: App; @@ -341,4 +343,16 @@ describe('handleToolResult', () => { // Assert setupOpenInTableauLink WAS called expect(vi.mocked(setupOpenInTableauLink)).toHaveBeenCalledTimes(1); }); + + it('reports telemetry via the app when a tool error occurs', async () => { + const errorResult: CallToolResult = { + isError: true, + content: [{ type: 'text', text: 'Tool execution failed' }], + }; + + await handleToolResult(mockApp, errorResult); + await new Promise((r) => setTimeout(r, 0)); + + expect(vi.mocked(reportMcpAppError)).toHaveBeenCalledWith(mockApp, 'TOOL_ERROR', undefined); + }); }); diff --git a/src/web/apps/src/lib/handleToolResult.ts b/src/web/apps/src/lib/handleToolResult.ts index 92fefae4e..b35337033 100644 --- a/src/web/apps/src/lib/handleToolResult.ts +++ b/src/web/apps/src/lib/handleToolResult.ts @@ -43,7 +43,7 @@ export function extractUrlObjectFromResult(result: CallToolResult): string { */ export async function handleToolResult(app: App, result: CallToolResult): Promise { if (!result || result.isError) { - showError('TOOL_ERROR'); + showError('TOOL_ERROR', undefined, app); return; } @@ -52,7 +52,7 @@ export async function handleToolResult(app: App, result: CallToolResult): Promis try { viewUrl = extractUrlObjectFromResult(result); } catch (e) { - showError('PARSE_ERROR', e); + showError('PARSE_ERROR', e, app); return; } @@ -60,7 +60,7 @@ export async function handleToolResult(app: App, result: CallToolResult): Promis try { await loadTableauEmbeddingApi(viewUrl); } catch (e) { - showError('EMBED_LOAD_ERROR', e); + showError('EMBED_LOAD_ERROR', e, app); return; } @@ -69,12 +69,12 @@ export async function handleToolResult(app: App, result: CallToolResult): Promis try { token = await callGetEmbedTokenTool(app); } catch (e) { - showError('AUTH_ERROR', e); + showError('AUTH_ERROR', e, app); return; } // Auth failure (runtime) - handled by onError callback - embedTableauViz(viewUrl, token, () => showError('AUTH_ERROR')); + embedTableauViz(viewUrl, token, () => showError('AUTH_ERROR', undefined, app)); const main = document.querySelector('.main'); if (main) { diff --git a/src/web/apps/src/lib/recordMcpAppErrorClient.test.ts b/src/web/apps/src/lib/recordMcpAppErrorClient.test.ts new file mode 100644 index 000000000..a1cf3ef85 --- /dev/null +++ b/src/web/apps/src/lib/recordMcpAppErrorClient.test.ts @@ -0,0 +1,64 @@ +import type { App } from '@modelcontextprotocol/ext-apps'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { reportMcpAppError } from './recordMcpAppErrorClient.js'; + +describe('reportMcpAppError', () => { + let mockApp: App; + let callServerTool: ReturnType; + + beforeEach(() => { + callServerTool = vi.fn().mockResolvedValue({}); + mockApp = { + getHostCapabilities: vi.fn().mockReturnValue({ serverTools: {} }), + callServerTool, + } as unknown as App; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('calls the record-mcp-app-error tool with scenario and Error message', () => { + reportMcpAppError(mockApp, 'PARSE_ERROR', new Error('bad json')); + + expect(callServerTool).toHaveBeenCalledWith({ + name: 'record-mcp-app-error', + arguments: { scenario: 'PARSE_ERROR', message: 'bad json' }, + }); + }); + + it('omits message when there is no cause', () => { + reportMcpAppError(mockApp, 'TOOL_ERROR'); + + expect(callServerTool).toHaveBeenCalledWith({ + name: 'record-mcp-app-error', + arguments: { scenario: 'TOOL_ERROR' }, + }); + }); + + it('does not call the tool when the host lacks serverTools capability', () => { + (mockApp.getHostCapabilities as ReturnType).mockReturnValue({}); + + reportMcpAppError(mockApp, 'AUTH_ERROR'); + + expect(callServerTool).not.toHaveBeenCalled(); + }); + + it('does not throw when callServerTool rejects (fire-and-forget)', async () => { + callServerTool.mockRejectedValue(new Error('transport failed')); + + expect(() => reportMcpAppError(mockApp, 'EMBED_LOAD_ERROR')).not.toThrow(); + // Let the rejected promise settle so the internal .catch runs. + await new Promise((r) => setTimeout(r, 0)); + }); + + it('does not throw when getHostCapabilities throws', () => { + (mockApp.getHostCapabilities as ReturnType).mockImplementation(() => { + throw new Error('not connected'); + }); + + expect(() => reportMcpAppError(mockApp, 'TOOL_ERROR')).not.toThrow(); + expect(callServerTool).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web/apps/src/lib/recordMcpAppErrorClient.ts b/src/web/apps/src/lib/recordMcpAppErrorClient.ts new file mode 100644 index 000000000..009d498c6 --- /dev/null +++ b/src/web/apps/src/lib/recordMcpAppErrorClient.ts @@ -0,0 +1,35 @@ +import type { App } from '@modelcontextprotocol/ext-apps'; + +/** + * Best-effort telemetry reporter for MCP app errors. Calls the app-only + * `record-mcp-app-error` server tool via the host proxy. Fire-and-forget: + * it never awaits, never throws, and silently no-ops when the host cannot + * proxy server tools. Telemetry must never break the error UI. + * + * @param app - The MCP App instance. + * @param scenario - The error category (e.g. the showError Scenario). + * @param cause - Optional underlying error; its message is forwarded when present. + */ +export function reportMcpAppError(app: App, scenario: string, cause?: unknown): void { + try { + if (!app.getHostCapabilities()?.serverTools) { + return; + } + + const message = toErrorMessage(cause); + const args = message !== undefined ? { scenario, message } : { scenario }; + + void app.callServerTool({ name: 'record-mcp-app-error', arguments: args }).catch(() => { + // Best-effort telemetry: swallow transport failures. + }); + } catch { + // Never let telemetry reporting break the error UI. + } +} + +function toErrorMessage(cause: unknown): string | undefined { + if (cause === undefined || cause === null) { + return undefined; + } + return cause instanceof Error ? cause.message : String(cause); +} diff --git a/src/web/apps/src/lib/showError.test.ts b/src/web/apps/src/lib/showError.test.ts index 373d4baa8..0096cecc6 100644 --- a/src/web/apps/src/lib/showError.test.ts +++ b/src/web/apps/src/lib/showError.test.ts @@ -1,8 +1,11 @@ /** * @vitest-environment jsdom */ +import type { App } from '@modelcontextprotocol/ext-apps'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +vi.mock('./recordMcpAppErrorClient.js'); +import { reportMcpAppError } from './recordMcpAppErrorClient.js'; import { showError } from './showError.js'; describe('showError', () => { @@ -125,4 +128,29 @@ describe('showError', () => { expect(document.querySelector('.mcp-app-error')).toBeNull(); }); + + it('reports telemetry with scenario and cause when app is provided', () => { + const app = {} as unknown as App; + const cause = new Error('JSON parse failed'); + + showError('PARSE_ERROR', cause, app); + + expect(vi.mocked(reportMcpAppError)).toHaveBeenCalledWith(app, 'PARSE_ERROR', cause); + }); + + it('does not report telemetry when app is not provided', () => { + showError('TOOL_ERROR'); + + expect(vi.mocked(reportMcpAppError)).not.toHaveBeenCalled(); + }); + + it('reports telemetry even when the container is missing', () => { + document.body.replaceChildren(); + const app = {} as unknown as App; + + showError('EMBED_LOAD_ERROR', undefined, app); + + expect(vi.mocked(reportMcpAppError)).toHaveBeenCalledWith(app, 'EMBED_LOAD_ERROR', undefined); + expect(document.querySelector('.mcp-app-error')).toBeNull(); + }); }); diff --git a/src/web/apps/src/lib/showError.ts b/src/web/apps/src/lib/showError.ts index 0f459462b..ff479a4b3 100644 --- a/src/web/apps/src/lib/showError.ts +++ b/src/web/apps/src/lib/showError.ts @@ -1,5 +1,8 @@ +import type { App } from '@modelcontextprotocol/ext-apps'; + import DISCONNECTED_SVG from '../assets/disconnected.svg?raw'; import { TABLEAU_VIZ_CONTAINER_ID } from './embedTableauViz.js'; +import { reportMcpAppError } from './recordMcpAppErrorClient.js'; export type Scenario = 'TOOL_ERROR' | 'PARSE_ERROR' | 'AUTH_ERROR' | 'EMBED_LOAD_ERROR'; @@ -28,8 +31,15 @@ const ERROR_UI: Record = { * Shows an error message in the tableau viz container * @param scenario - The error scenario to display * @param cause - Optional error that caused this scenario + * @param app - Optional MCP App instance for telemetry reporting */ -export function showError(scenario: Scenario, cause?: unknown): void { +export function showError(scenario: Scenario, cause?: unknown, app?: App): void { + // Report telemetry first (best-effort), so errors are recorded even when the + // container is missing and the error UI cannot be rendered. + if (app) { + reportMcpAppError(app, scenario, cause); + } + const container = document.getElementById(TABLEAU_VIZ_CONTAINER_ID); if (!container) { return; From 950c0c166fcab9bfbce1a4a5479743b964e5b848 Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Fri, 17 Jul 2026 14:57:54 -0400 Subject: [PATCH 2/9] Make MCP app error screen background fully white MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The error screen ("Unable to load this Tableau view") rendered on an off-white background — the browser default showing through the transparent body/.main/.viz-container ancestors, since no rule set a background. Paint white on body:has(.mcp-app-error) so it propagates to the whole viewport edge-to-edge, scoped to the error state only so the normal viz render is unaffected. --- src/web/apps/src/mcp-app.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/web/apps/src/mcp-app.css b/src/web/apps/src/mcp-app.css index 85cc2eae0..2bf32117d 100644 --- a/src/web/apps/src/mcp-app.css +++ b/src/web/apps/src/mcp-app.css @@ -11,6 +11,10 @@ body { font-family: system-ui, -apple-system, sans-serif; } +body:has(.mcp-app-error) { + background-color: #FFFFFF; +} + .main { width: 100%; margin: 0; From 781bcd694a58b6c32a9e8ccec8cf1ed0dbf411bd Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Fri, 17 Jul 2026 18:54:46 -0400 Subject: [PATCH 3/9] Generalize MCP app telemetry to record-event and record link clicks Rename the app-only telemetry tool and its client from the error-specific record-mcp-app-error / reportMcpAppError to the generic record-event / recordEvent, so it can carry any MCP-app event, not just errors. The tool name is updated in lockstep across the client call, the OAuth scope allowlist, the passthrough-auth allowlist, and toolName.ts. Add an MCP_APP_CLICKED event: clicking "Open in Tableau" now fires recordEvent(app, 'MCP_APP_CLICKED', url) fire-and-forget before openLink. Reshape the emitted event to match: event type tableau_mcp_event.completed -> tableau_mcp_event, and the payload carries event_type + message in place of the former scenario / request_id / session_id fields. --- src/server/oauth/scopes.ts | 6 +- src/server/passthroughAuthMiddleware.test.ts | 4 +- .../productTelemetry/telemetryForwarder.ts | 2 +- .../recordEvent.test.ts} | 30 ++++----- .../recordEvent.ts} | 30 +++++---- src/tools/web/tool.test.ts | 12 ++++ src/tools/web/tool.ts | 7 ++- src/tools/web/toolName.ts | 9 +-- src/tools/web/tools.ts | 4 +- src/utils/extractToolErrorMessage.test.ts | 62 +++++++++++++++++++ src/utils/extractToolErrorMessage.ts | 19 ++++++ src/web/apps/src/lib/handleToolResult.test.ts | 19 ++++-- src/web/apps/src/lib/handleToolResult.ts | 4 +- .../apps/src/lib/openInTableauLink.test.ts | 20 ++++++ src/web/apps/src/lib/openInTableauLink.ts | 3 + ...ient.test.ts => recordEventClient.test.ts} | 26 ++++---- src/web/apps/src/lib/recordEventClient.ts | 36 +++++++++++ .../apps/src/lib/recordMcpAppErrorClient.ts | 35 ----------- src/web/apps/src/lib/showError.test.ts | 10 +-- src/web/apps/src/lib/showError.ts | 6 +- 20 files changed, 235 insertions(+), 109 deletions(-) rename src/tools/web/{recordMcpAppError/recordMcpAppError.test.ts => recordEvent/recordEvent.test.ts} (71%) rename src/tools/web/{recordMcpAppError/recordMcpAppError.ts => recordEvent/recordEvent.ts} (62%) create mode 100644 src/utils/extractToolErrorMessage.test.ts create mode 100644 src/utils/extractToolErrorMessage.ts rename src/web/apps/src/lib/{recordMcpAppErrorClient.test.ts => recordEventClient.test.ts} (65%) create mode 100644 src/web/apps/src/lib/recordEventClient.ts delete mode 100644 src/web/apps/src/lib/recordMcpAppErrorClient.ts diff --git a/src/server/oauth/scopes.ts b/src/server/oauth/scopes.ts index a813c730c..44fa218f9 100644 --- a/src/server/oauth/scopes.ts +++ b/src/server/oauth/scopes.ts @@ -291,8 +291,8 @@ const toolScopeMap: Record< mcp: [], api: new Set(), }, - // MCP-app error telemetry relay: no Tableau REST API calls, no content scope required. - 'record-mcp-app-error': { + // MCP-app event telemetry relay: no Tableau REST API calls, no content scope required. + 'record-event': { mcp: [], api: new Set(), }, @@ -400,7 +400,7 @@ function getEnabledToolNames(): Set { // human-gesture confirm steps for their preview tools and only exist when the iframe can render. if (!featureGate.isFeatureEnabled('mcp-apps')) { enabledTools.delete('get-embed-token'); - enabledTools.delete('record-mcp-app-error'); + enabledTools.delete('record-event'); enabledTools.delete('confirm-delete-workbook'); enabledTools.delete('confirm-delete-datasource'); enabledTools.delete('confirm-delete-extract-refresh-task'); diff --git a/src/server/passthroughAuthMiddleware.test.ts b/src/server/passthroughAuthMiddleware.test.ts index b51f82053..34af70fff 100644 --- a/src/server/passthroughAuthMiddleware.test.ts +++ b/src/server/passthroughAuthMiddleware.test.ts @@ -21,9 +21,9 @@ const TOOLS_WITHOUT_API_SCOPES_WITH_PASSTHROUGH_GUARD: ReadonlyArray { diff --git a/src/telemetry/productTelemetry/telemetryForwarder.ts b/src/telemetry/productTelemetry/telemetryForwarder.ts index b70ab3326..064ffdef9 100644 --- a/src/telemetry/productTelemetry/telemetryForwarder.ts +++ b/src/telemetry/productTelemetry/telemetryForwarder.ts @@ -7,7 +7,7 @@ type PropertiesType = { [key: string]: ValidPropertyValueType }; const DEFAULT_HOST_NAME = 'External'; const SERVICE_NAME = 'tableau-mcp'; -export type TelemetryEventType = 'tool_call' | 'tableau_mcp_event.completed'; +export type TelemetryEventType = 'tool_call' | 'tableau_mcp_event'; export type ProductTelemetryBase = { endpoint: string; diff --git a/src/tools/web/recordMcpAppError/recordMcpAppError.test.ts b/src/tools/web/recordEvent/recordEvent.test.ts similarity index 71% rename from src/tools/web/recordMcpAppError/recordMcpAppError.test.ts rename to src/tools/web/recordEvent/recordEvent.test.ts index 3e90c3271..c6cc4a796 100644 --- a/src/tools/web/recordMcpAppError/recordMcpAppError.test.ts +++ b/src/tools/web/recordEvent/recordEvent.test.ts @@ -4,11 +4,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { WebMcpServer } from '../../../server.web.js'; import { Provider } from '../../../utils/provider.js'; import { getMockRequestHandlerExtra } from '../toolContext.mock.js'; -import { getRecordMcpAppErrorTool } from './recordMcpAppError.js'; +import { getRecordEventTool } from './recordEvent.js'; // Mock getProductTelemetry so we can assert on the forwarder's send(). Note that // WebTool.logAndExecute also emits an automatic 'tool_call' event through the same -// forwarder, so the spy is called for both 'tool_call' and 'tableau_mcp_event.completed'. +// forwarder, so the spy is called for both 'tool_call' and 'tableau_mcp_event'. vi.mock('../../../telemetry/productTelemetry/telemetryForwarder.js', async (importOriginal) => { const actual = await importOriginal< @@ -21,7 +21,7 @@ import { getProductTelemetry } from '../../../telemetry/productTelemetry/telemet type Extra = ReturnType; -describe('getRecordMcpAppErrorTool', () => { +describe('getRecordEventTool', () => { let sendSpy: ReturnType; beforeEach(() => { @@ -34,27 +34,27 @@ describe('getRecordMcpAppErrorTool', () => { }); it('should create a tool instance with correct properties', async () => { - const tool = getRecordMcpAppErrorTool(new WebMcpServer()); + const tool = getRecordEventTool(new WebMcpServer()); const annotations = await Provider.from(tool.annotations); - expect(tool.name).toBe('record-mcp-app-error'); + expect(tool.name).toBe('record-event'); expect(annotations?.readOnlyHint).toBe(true); expect(annotations?.openWorldHint).toBe(false); }); it('should set visibility to app-only', () => { - const tool = getRecordMcpAppErrorTool(new WebMcpServer()); + const tool = getRecordEventTool(new WebMcpServer()); expect(tool.meta?.ui?.visibility).toEqual(['app']); }); - it('sends an tableau_mcp_event.completed event with the scenario, message and server context', async () => { + it('sends an tableau_mcp_event event with the event_type, message and server context', async () => { const extra = getMockRequestHandlerExtra(); - const result = await getToolResult(extra, { scenario: 'PARSE_ERROR', message: 'bad json' }); + const result = await getToolResult(extra, { event_type: 'PARSE_ERROR', message: 'bad json' }); expect(result.isError).toBe(false); expect(sendSpy).toHaveBeenCalledWith( - 'tableau_mcp_event.completed', + 'tableau_mcp_event', expect.objectContaining({ - scenario: 'PARSE_ERROR', + event_type: 'PARSE_ERROR', message: 'bad json', podname: extra.config.server, is_hyperforce: extra.config.isHyperforce, @@ -64,20 +64,20 @@ describe('getRecordMcpAppErrorTool', () => { it('defaults message to empty string when omitted', async () => { const extra = getMockRequestHandlerExtra(); - await getToolResult(extra, { scenario: 'EMBED_LOAD_ERROR' }); + await getToolResult(extra, { event_type: 'EMBED_LOAD_ERROR', message: undefined }); expect(sendSpy).toHaveBeenCalledWith( - 'tableau_mcp_event.completed', - expect.objectContaining({ scenario: 'EMBED_LOAD_ERROR', message: '' }), + 'tableau_mcp_event', + expect.objectContaining({ event_type: 'EMBED_LOAD_ERROR', message: '' }), ); }); }); async function getToolResult( extra: Extra, - args: { scenario: string; message?: string }, + args: { event_type: string; message: string | undefined }, ): Promise { - const tool = getRecordMcpAppErrorTool(new WebMcpServer()); + const tool = getRecordEventTool(new WebMcpServer()); const callback = await Provider.from(tool.callback); return await callback(args, extra); } diff --git a/src/tools/web/recordMcpAppError/recordMcpAppError.ts b/src/tools/web/recordEvent/recordEvent.ts similarity index 62% rename from src/tools/web/recordMcpAppError/recordMcpAppError.ts rename to src/tools/web/recordEvent/recordEvent.ts index 544d56fa5..1863215fb 100644 --- a/src/tools/web/recordMcpAppError/recordMcpAppError.ts +++ b/src/tools/web/recordEvent/recordEvent.ts @@ -9,29 +9,29 @@ import { WebTool } from '../tool.js'; // Starting field set — the final app-supplied schema is expected to grow later. const paramsSchema = { - scenario: z + event_type: z .string() .describe( - 'The MCP app error category, e.g. TOOL_ERROR, PARSE_ERROR, AUTH_ERROR, EMBED_LOAD_ERROR.', + 'The event type for product telemetry, e.g. TOOL_ERROR, PARSE_ERROR, AUTH_ERROR, EMBED_LOAD_ERROR, MCP_APP_CLICKED.', ), - message: z.string().optional().describe('Optional detail describing the error cause.'), + message: z.string().optional().describe('Optional detail or context for the event.'), }; /** - * Records a product-telemetry event when an error occurs in the MCP app UI. + * Records a product-telemetry event from the MCP app UI (errors, user actions, etc.). * Called by the app (never the model) via app.callServerTool. Mirrors the * server-side 'tool_call' telemetry pattern, enriching the event with request * context the browser bundle does not have. */ -export const getRecordMcpAppErrorTool = (server: WebMcpServer): WebTool => { - const recordMcpAppErrorTool = new WebTool({ +export const getRecordEventTool = (server: WebMcpServer): WebTool => { + const recordEventTool = new WebTool({ server, - name: 'record-mcp-app-error', + name: 'record-event', description: - 'Records a product-telemetry event when an error occurs in the MCP app UI. This tool is only visible to the app, never the model. It takes an error scenario and optional detail, forwards a telemetry event, and returns immediately.', + 'Records a product-telemetry event from the MCP app UI (errors, user actions, etc.). This tool is only visible to the app, never the model. It takes an event type and optional detail, forwards a telemetry event, and returns immediately.', paramsSchema, annotations: { - title: 'Record MCP App Error', + title: 'Record Event', readOnlyHint: true, destructiveHint: false, idempotentHint: true, @@ -44,11 +44,11 @@ export const getRecordMcpAppErrorTool = (server: WebMcpServer): WebTool => { - return recordMcpAppErrorTool.logAndExecute<{ recorded: true }>({ + return recordEventTool.logAndExecute<{ recorded: true }>({ extra, args, callback: async () => { - const { config, requestId, sessionId } = extra; + const { config } = extra; const productTelemetryForwarder = getProductTelemetry( config.productTelemetryEndpoint, @@ -56,11 +56,9 @@ export const getRecordMcpAppErrorTool = (server: WebMcpServer): WebTool { is_hyperforce: false, success: true, error_code: '', + error_message: '', }), ); }); @@ -252,6 +253,7 @@ describe('Tool', () => { is_hyperforce: false, success: false, error_code: '500', + error_message: 'requestId: 2, error: Callback failed', }), ); }); @@ -582,6 +584,16 @@ describe('Tool', () => { const parsed = JSON.parse(result.content[0].text); expect(parsed.data).toEqual(rawApiData.toString()); expect(parsed.warning).toContain('Expected string, received object'); + + // The passthrough result carries the full API payload but is isError: false, so it must + // NOT leak into telemetry's error_message (keyed off isError, not the false `success`). + expect(mockTelemetrySend).toHaveBeenCalledWith( + 'tool_call', + expect.objectContaining({ + success: false, + error_message: '', + }), + ); }); it('should return isError: false with validation warning for discriminatedUnion schema errors', async () => { diff --git a/src/tools/web/tool.ts b/src/tools/web/tool.ts index 7567d0404..685e7fa19 100644 --- a/src/tools/web/tool.ts +++ b/src/tools/web/tool.ts @@ -12,6 +12,7 @@ import { } from '../../telemetry/clientDisplayName.js'; import { getTelemetryProvider } from '../../telemetry/init.js'; import { getProductTelemetry } from '../../telemetry/productTelemetry/telemetryForwarder.js'; +import { extractToolErrorMessage } from '../../utils/extractToolErrorMessage.js'; import { getExceptionMessage } from '../../utils/getExceptionMessage.js'; import { getHttpStatus } from '../../utils/getHttpStatus.js'; import { LogAndExecuteParams, Tool, ToolParams } from '../tool.js'; @@ -162,7 +163,7 @@ export class WebTool extends T let success = false; let errorCode = ''; // HTTP status category: "4xx", "5xx", or empty for successful calls - let toolResult: CallToolResult; + let toolResult: CallToolResult | undefined; try { const result = await callback(); @@ -232,6 +233,10 @@ export class WebTool extends T is_hyperforce: config.isHyperforce, success, error_code: errorCode, + // Only populated for genuine error results (isError: true). The ZodiosValidationError + // passthrough returns isError: false with the full API payload, so keying off isError + // (not !success) keeps successful response data out of telemetry. + error_message: toolResult?.isError ? extractToolErrorMessage(toolResult) : '', oauth_client_id: sanitizeClientIdForTelemetry(oauthClientId), oauth_client_display_name: getClientDisplayName(oauthClientId) ?? sanitizeClientIdForTelemetry(oauthClientId), diff --git a/src/tools/web/toolName.ts b/src/tools/web/toolName.ts index 38229f008..af7b3c189 100644 --- a/src/tools/web/toolName.ts +++ b/src/tools/web/toolName.ts @@ -18,7 +18,7 @@ export const webToolNames = [ 'query-datasource', 'get-datasource-metadata', 'get-embed-token', - 'record-mcp-app-error', + 'record-event', 'get-workbook', 'get-view', 'get-view-data', @@ -98,12 +98,7 @@ export const webToolGroups = { ], jobs: ['list-jobs'], users: ['list-users'], - 'token-management': [ - 'get-embed-token', - 'record-mcp-app-error', - 'revoke-access-token', - 'reset-consent', - ], + 'token-management': ['get-embed-token', 'record-event', 'revoke-access-token', 'reset-consent'], 'admin-insights': [ 'query-admin-insights', 'query-admin-insights-ts-events', diff --git a/src/tools/web/tools.ts b/src/tools/web/tools.ts index cc9fe76af..7263321e3 100644 --- a/src/tools/web/tools.ts +++ b/src/tools/web/tools.ts @@ -25,7 +25,7 @@ import { getListPulseMetricsFromMetricDefinitionIdTool } from './pulse/listMetri import { getListPulseMetricsFromMetricIdsTool } from './pulse/listMetricsFromMetricIds/listPulseMetricsFromMetricIds.js'; import { getListPulseMetricSubscriptionsTool } from './pulse/listMetricSubscriptions/listPulseMetricSubscriptions.js'; import { getQueryDatasourceTool } from './queryDatasource/queryDatasource.js'; -import { getRecordMcpAppErrorTool } from './recordMcpAppError/recordMcpAppError.js'; +import { getRecordEventTool } from './recordEvent/recordEvent.js'; import { getResetConsentTool } from './resetConsent/resetConsent.js'; import { getRevokeAccessTokenTool } from './revokeAccessToken/revokeAccessToken.js'; import { getListUsersTool } from './users/listUsers.js'; @@ -44,7 +44,7 @@ import { getListWorkbooksTool } from './workbooks/listWorkbooks.js'; export const webToolFactories = [ getGetDatasourceMetadataTool, getEmbedTokenTool, - getRecordMcpAppErrorTool, + getRecordEventTool, getListDatasourcesTool, getDeleteDatasourceTool, getConfirmDeleteDatasourceTool, diff --git a/src/utils/extractToolErrorMessage.test.ts b/src/utils/extractToolErrorMessage.test.ts new file mode 100644 index 000000000..7546662ba --- /dev/null +++ b/src/utils/extractToolErrorMessage.test.ts @@ -0,0 +1,62 @@ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; + +import { extractToolErrorMessage } from './extractToolErrorMessage.js'; + +describe('extractToolErrorMessage', () => { + it('returns the text of a single text content block', () => { + const result: CallToolResult = { + isError: true, + content: [{ type: 'text', text: 'Request failed with status code 404' }], + }; + + expect(extractToolErrorMessage(result)).toBe('Request failed with status code 404'); + }); + + it('joins multiple text blocks with a space', () => { + const result: CallToolResult = { + isError: true, + content: [ + { type: 'text', text: 'first' }, + { type: 'text', text: 'second' }, + ], + }; + + expect(extractToolErrorMessage(result)).toBe('first second'); + }); + + it('ignores non-text content blocks', () => { + const result = { + isError: true, + content: [ + { type: 'image', data: 'abc', mimeType: 'image/png' }, + { type: 'text', text: 'only this' }, + ], + } as unknown as CallToolResult; + + expect(extractToolErrorMessage(result)).toBe('only this'); + }); + + it('returns an empty string when there are no text blocks', () => { + const result = { + isError: true, + content: [{ type: 'image', data: 'abc', mimeType: 'image/png' }], + } as unknown as CallToolResult; + + expect(extractToolErrorMessage(result)).toBe(''); + }); + + it('returns an empty string when the text is empty or whitespace only', () => { + const result: CallToolResult = { + isError: true, + content: [{ type: 'text', text: ' ' }], + }; + + expect(extractToolErrorMessage(result)).toBe(''); + }); + + it('returns an empty string when content is not an array', () => { + const result = { isError: true, content: undefined } as unknown as CallToolResult; + + expect(extractToolErrorMessage(result)).toBe(''); + }); +}); diff --git a/src/utils/extractToolErrorMessage.ts b/src/utils/extractToolErrorMessage.ts new file mode 100644 index 000000000..0d1383ad4 --- /dev/null +++ b/src/utils/extractToolErrorMessage.ts @@ -0,0 +1,19 @@ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; + +/** + * Extracts a human-readable error message from a failed tool result's text + * content, so it can be forwarded as telemetry detail. Joins all text blocks + * and returns an empty string when the result carries no usable text. + */ +export function extractToolErrorMessage(result: CallToolResult): string { + const content = result.content; + if (!Array.isArray(content)) { + return ''; + } + + return content + .filter((item): item is { type: 'text'; text: string } => item?.type === 'text') + .map((item) => item.text) + .join(' ') + .trim(); +} diff --git a/src/web/apps/src/lib/handleToolResult.test.ts b/src/web/apps/src/lib/handleToolResult.test.ts index e4012d3d3..528099aad 100644 --- a/src/web/apps/src/lib/handleToolResult.test.ts +++ b/src/web/apps/src/lib/handleToolResult.test.ts @@ -12,13 +12,13 @@ vi.mock('./getEmbedTokenToolClient.js'); vi.mock('./embedTableauViz.js'); vi.mock('./loadTableauEmbeddingApi.js'); vi.mock('./openInTableauLink.js'); -vi.mock('./recordMcpAppErrorClient.js'); +vi.mock('./recordEventClient.js'); import { embedTableauViz } from './embedTableauViz.js'; import { callGetEmbedTokenTool } from './getEmbedTokenToolClient.js'; import { loadTableauEmbeddingApi } from './loadTableauEmbeddingApi.js'; import { setupOpenInTableauLink } from './openInTableauLink.js'; -import { reportMcpAppError } from './recordMcpAppErrorClient.js'; +import { recordEvent } from './recordEventClient.js'; describe('handleToolResult', () => { let mockApp: App; @@ -344,7 +344,7 @@ describe('handleToolResult', () => { expect(vi.mocked(setupOpenInTableauLink)).toHaveBeenCalledTimes(1); }); - it('reports telemetry via the app when a tool error occurs', async () => { + it('reports telemetry with the tool error message when a tool error occurs', async () => { const errorResult: CallToolResult = { isError: true, content: [{ type: 'text', text: 'Tool execution failed' }], @@ -353,6 +353,17 @@ describe('handleToolResult', () => { await handleToolResult(mockApp, errorResult); await new Promise((r) => setTimeout(r, 0)); - expect(vi.mocked(reportMcpAppError)).toHaveBeenCalledWith(mockApp, 'TOOL_ERROR', undefined); + expect(vi.mocked(recordEvent)).toHaveBeenCalledWith( + mockApp, + 'TOOL_ERROR', + 'Tool execution failed', + ); + }); + + it('reports telemetry with undefined cause when the tool result is null', async () => { + await handleToolResult(mockApp, null as any); + await new Promise((r) => setTimeout(r, 0)); + + expect(vi.mocked(recordEvent)).toHaveBeenCalledWith(mockApp, 'TOOL_ERROR', undefined); }); }); diff --git a/src/web/apps/src/lib/handleToolResult.ts b/src/web/apps/src/lib/handleToolResult.ts index b35337033..ab41c5dbd 100644 --- a/src/web/apps/src/lib/handleToolResult.ts +++ b/src/web/apps/src/lib/handleToolResult.ts @@ -2,6 +2,7 @@ import type { App } from '@modelcontextprotocol/ext-apps'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; +import { extractToolErrorMessage } from '../../../../utils/extractToolErrorMessage.js'; import { embedTableauViz } from './embedTableauViz.js'; import { callGetEmbedTokenTool } from './getEmbedTokenToolClient.js'; import { loadTableauEmbeddingApi } from './loadTableauEmbeddingApi.js'; @@ -43,7 +44,8 @@ export function extractUrlObjectFromResult(result: CallToolResult): string { */ export async function handleToolResult(app: App, result: CallToolResult): Promise { if (!result || result.isError) { - showError('TOOL_ERROR', undefined, app); + const cause = result ? extractToolErrorMessage(result) : undefined; + showError('TOOL_ERROR', cause, app); return; } diff --git a/src/web/apps/src/lib/openInTableauLink.test.ts b/src/web/apps/src/lib/openInTableauLink.test.ts index b900b3f3b..4231c1edb 100644 --- a/src/web/apps/src/lib/openInTableauLink.test.ts +++ b/src/web/apps/src/lib/openInTableauLink.test.ts @@ -4,7 +4,10 @@ import type { App } from '@modelcontextprotocol/ext-apps'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +vi.mock('./recordEventClient.js'); + import { setupOpenInTableauLink } from './openInTableauLink.js'; +import { recordEvent } from './recordEventClient.js'; describe('setupOpenInTableauLink', () => { let mockApp: App; @@ -111,6 +114,23 @@ describe('setupOpenInTableauLink', () => { expect(preventDefaultSpy).toHaveBeenCalled(); }); + it('should call recordEvent with MCP_APP_CLICKED when link is clicked', async () => { + const url = 'https://tableau.example.com/views/workbook/view'; + + setupOpenInTableauLink(mockApp, url, container); + + const linkElement = container.querySelector('#openInTableauLink') as HTMLAnchorElement; + expect(linkElement).not.toBeNull(); + + // Click the link + linkElement.click(); + + // Wait for async handler + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(vi.mocked(recordEvent)).toHaveBeenCalledWith(mockApp, 'MCP_APP_CLICKED', url); + }); + it('should show inline error when openLink returns isError true', async () => { const url = 'https://tableau.example.com/views/workbook/view'; mockApp.openLink = vi.fn().mockResolvedValue({ isError: true }); diff --git a/src/web/apps/src/lib/openInTableauLink.ts b/src/web/apps/src/lib/openInTableauLink.ts index bc3a14532..d1a8a0ec7 100644 --- a/src/web/apps/src/lib/openInTableauLink.ts +++ b/src/web/apps/src/lib/openInTableauLink.ts @@ -1,5 +1,7 @@ import type { App } from '@modelcontextprotocol/ext-apps'; +import { recordEvent } from './recordEventClient.js'; + /** * Shows an inline error message when the link fails to open. * @@ -60,6 +62,7 @@ export function setupOpenInTableauLink(app: App, url: string, container: HTMLEle // Set onclick handler to use host-mediated link opening link.onclick = async (e) => { e.preventDefault(); + recordEvent(app, 'MCP_APP_CLICKED', url); try { const result = await app.openLink({ url }); diff --git a/src/web/apps/src/lib/recordMcpAppErrorClient.test.ts b/src/web/apps/src/lib/recordEventClient.test.ts similarity index 65% rename from src/web/apps/src/lib/recordMcpAppErrorClient.test.ts rename to src/web/apps/src/lib/recordEventClient.test.ts index a1cf3ef85..d8a6068df 100644 --- a/src/web/apps/src/lib/recordMcpAppErrorClient.test.ts +++ b/src/web/apps/src/lib/recordEventClient.test.ts @@ -1,9 +1,9 @@ import type { App } from '@modelcontextprotocol/ext-apps'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { reportMcpAppError } from './recordMcpAppErrorClient.js'; +import { recordEvent } from './recordEventClient.js'; -describe('reportMcpAppError', () => { +describe('recordEvent', () => { let mockApp: App; let callServerTool: ReturnType; @@ -19,28 +19,28 @@ describe('reportMcpAppError', () => { vi.restoreAllMocks(); }); - it('calls the record-mcp-app-error tool with scenario and Error message', () => { - reportMcpAppError(mockApp, 'PARSE_ERROR', new Error('bad json')); + it('calls the record-event tool with event_type and Error message', () => { + recordEvent(mockApp, 'PARSE_ERROR', new Error('bad json')); expect(callServerTool).toHaveBeenCalledWith({ - name: 'record-mcp-app-error', - arguments: { scenario: 'PARSE_ERROR', message: 'bad json' }, + name: 'record-event', + arguments: { event_type: 'PARSE_ERROR', message: 'bad json' }, }); }); - it('omits message when there is no cause', () => { - reportMcpAppError(mockApp, 'TOOL_ERROR'); + it('omits message when there is no detail', () => { + recordEvent(mockApp, 'TOOL_ERROR'); expect(callServerTool).toHaveBeenCalledWith({ - name: 'record-mcp-app-error', - arguments: { scenario: 'TOOL_ERROR' }, + name: 'record-event', + arguments: { event_type: 'TOOL_ERROR' }, }); }); it('does not call the tool when the host lacks serverTools capability', () => { (mockApp.getHostCapabilities as ReturnType).mockReturnValue({}); - reportMcpAppError(mockApp, 'AUTH_ERROR'); + recordEvent(mockApp, 'AUTH_ERROR'); expect(callServerTool).not.toHaveBeenCalled(); }); @@ -48,7 +48,7 @@ describe('reportMcpAppError', () => { it('does not throw when callServerTool rejects (fire-and-forget)', async () => { callServerTool.mockRejectedValue(new Error('transport failed')); - expect(() => reportMcpAppError(mockApp, 'EMBED_LOAD_ERROR')).not.toThrow(); + expect(() => recordEvent(mockApp, 'EMBED_LOAD_ERROR')).not.toThrow(); // Let the rejected promise settle so the internal .catch runs. await new Promise((r) => setTimeout(r, 0)); }); @@ -58,7 +58,7 @@ describe('reportMcpAppError', () => { throw new Error('not connected'); }); - expect(() => reportMcpAppError(mockApp, 'TOOL_ERROR')).not.toThrow(); + expect(() => recordEvent(mockApp, 'TOOL_ERROR')).not.toThrow(); expect(callServerTool).not.toHaveBeenCalled(); }); }); diff --git a/src/web/apps/src/lib/recordEventClient.ts b/src/web/apps/src/lib/recordEventClient.ts new file mode 100644 index 000000000..fb0644951 --- /dev/null +++ b/src/web/apps/src/lib/recordEventClient.ts @@ -0,0 +1,36 @@ +import type { App } from '@modelcontextprotocol/ext-apps'; + +/** + * Best-effort telemetry reporter for MCP app events (errors, user actions, etc.). + * Calls the app-only `record-event` server tool via the host proxy. Fire-and-forget: + * it never awaits, never throws, and silently no-ops when the host cannot + * proxy server tools. Telemetry must never block the UI. + * + * @param app - The MCP App instance. + * @param eventType - The event type (e.g. 'TOOL_ERROR', 'MCP_APP_CLICKED'). + * @param detail - Optional detail context (error message, URL, etc.). + */ +export function recordEvent(app: App, eventType: string, detail?: unknown): void { + try { + if (!app.getHostCapabilities()?.serverTools) { + return; + } + + const message = toMessage(detail); + const args = + message !== undefined ? { event_type: eventType, message } : { event_type: eventType }; + + void app.callServerTool({ name: 'record-event', arguments: args }).catch(() => { + // Best-effort telemetry: swallow transport failures. + }); + } catch { + // Never let telemetry reporting break the UI. + } +} + +function toMessage(detail: unknown): string | undefined { + if (detail === undefined || detail === null) { + return undefined; + } + return detail instanceof Error ? detail.message : String(detail); +} diff --git a/src/web/apps/src/lib/recordMcpAppErrorClient.ts b/src/web/apps/src/lib/recordMcpAppErrorClient.ts deleted file mode 100644 index 009d498c6..000000000 --- a/src/web/apps/src/lib/recordMcpAppErrorClient.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { App } from '@modelcontextprotocol/ext-apps'; - -/** - * Best-effort telemetry reporter for MCP app errors. Calls the app-only - * `record-mcp-app-error` server tool via the host proxy. Fire-and-forget: - * it never awaits, never throws, and silently no-ops when the host cannot - * proxy server tools. Telemetry must never break the error UI. - * - * @param app - The MCP App instance. - * @param scenario - The error category (e.g. the showError Scenario). - * @param cause - Optional underlying error; its message is forwarded when present. - */ -export function reportMcpAppError(app: App, scenario: string, cause?: unknown): void { - try { - if (!app.getHostCapabilities()?.serverTools) { - return; - } - - const message = toErrorMessage(cause); - const args = message !== undefined ? { scenario, message } : { scenario }; - - void app.callServerTool({ name: 'record-mcp-app-error', arguments: args }).catch(() => { - // Best-effort telemetry: swallow transport failures. - }); - } catch { - // Never let telemetry reporting break the error UI. - } -} - -function toErrorMessage(cause: unknown): string | undefined { - if (cause === undefined || cause === null) { - return undefined; - } - return cause instanceof Error ? cause.message : String(cause); -} diff --git a/src/web/apps/src/lib/showError.test.ts b/src/web/apps/src/lib/showError.test.ts index 0096cecc6..9ab18d214 100644 --- a/src/web/apps/src/lib/showError.test.ts +++ b/src/web/apps/src/lib/showError.test.ts @@ -4,8 +4,8 @@ import type { App } from '@modelcontextprotocol/ext-apps'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('./recordMcpAppErrorClient.js'); -import { reportMcpAppError } from './recordMcpAppErrorClient.js'; +vi.mock('./recordEventClient.js'); +import { recordEvent } from './recordEventClient.js'; import { showError } from './showError.js'; describe('showError', () => { @@ -135,13 +135,13 @@ describe('showError', () => { showError('PARSE_ERROR', cause, app); - expect(vi.mocked(reportMcpAppError)).toHaveBeenCalledWith(app, 'PARSE_ERROR', cause); + expect(vi.mocked(recordEvent)).toHaveBeenCalledWith(app, 'PARSE_ERROR', cause); }); it('does not report telemetry when app is not provided', () => { showError('TOOL_ERROR'); - expect(vi.mocked(reportMcpAppError)).not.toHaveBeenCalled(); + expect(vi.mocked(recordEvent)).not.toHaveBeenCalled(); }); it('reports telemetry even when the container is missing', () => { @@ -150,7 +150,7 @@ describe('showError', () => { showError('EMBED_LOAD_ERROR', undefined, app); - expect(vi.mocked(reportMcpAppError)).toHaveBeenCalledWith(app, 'EMBED_LOAD_ERROR', undefined); + expect(vi.mocked(recordEvent)).toHaveBeenCalledWith(app, 'EMBED_LOAD_ERROR', undefined); expect(document.querySelector('.mcp-app-error')).toBeNull(); }); }); diff --git a/src/web/apps/src/lib/showError.ts b/src/web/apps/src/lib/showError.ts index ff479a4b3..9f5c377be 100644 --- a/src/web/apps/src/lib/showError.ts +++ b/src/web/apps/src/lib/showError.ts @@ -2,7 +2,7 @@ import type { App } from '@modelcontextprotocol/ext-apps'; import DISCONNECTED_SVG from '../assets/disconnected.svg?raw'; import { TABLEAU_VIZ_CONTAINER_ID } from './embedTableauViz.js'; -import { reportMcpAppError } from './recordMcpAppErrorClient.js'; +import { recordEvent } from './recordEventClient.js'; export type Scenario = 'TOOL_ERROR' | 'PARSE_ERROR' | 'AUTH_ERROR' | 'EMBED_LOAD_ERROR'; @@ -37,7 +37,7 @@ export function showError(scenario: Scenario, cause?: unknown, app?: App): void // Report telemetry first (best-effort), so errors are recorded even when the // container is missing and the error UI cannot be rendered. if (app) { - reportMcpAppError(app, scenario, cause); + recordEvent(app, scenario, cause); } const container = document.getElementById(TABLEAU_VIZ_CONTAINER_ID); @@ -45,8 +45,6 @@ export function showError(scenario: Scenario, cause?: unknown, app?: App): void return; } - console.error(ERROR_UI[scenario].logCode, cause); - const errorElement = document.createElement('div'); errorElement.className = 'mcp-app-error'; errorElement.setAttribute('role', 'alert'); From 646a1ac25028520dc18c244e21213920acec8cce Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Sun, 19 Jul 2026 13:20:56 -0400 Subject: [PATCH 4/9] Bump version to 3.3.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 87c8dfc3c..c8a6d8acb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tableau/mcp-server", - "version": "3.2.0", + "version": "3.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tableau/mcp-server", - "version": "3.2.0", + "version": "3.3.0", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/ext-apps": "^1.7.2", diff --git a/package.json b/package.json index 6dff4b814..fa46b665f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@tableau/mcp-server", "description": "Helping agents see and understand data.", - "version": "3.2.0", + "version": "3.3.0", "repository": { "type": "git", "url": "git+https://github.com/tableau/tableau-mcp.git" From 5479e521c13b04fd763e3413f130aadc8d49b1e1 Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Mon, 20 Jul 2026 18:22:26 -0400 Subject: [PATCH 5/9] Forward HITL confirm tool-error message to telemetry via extractToolErrorMessage --- src/web/apps/src/lib/handleConfirmResult.test.ts | 2 +- src/web/apps/src/lib/handleConfirmResult.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/web/apps/src/lib/handleConfirmResult.test.ts b/src/web/apps/src/lib/handleConfirmResult.test.ts index 5f8ebf4fd..3cbb86bc1 100644 --- a/src/web/apps/src/lib/handleConfirmResult.test.ts +++ b/src/web/apps/src/lib/handleConfirmResult.test.ts @@ -56,7 +56,7 @@ describe('handleConfirmResult', () => { it('shows error UI when tool returns error result (isError: true)', () => { handleConfirmResult(mockApp, { isError: true, content: [{ type: 'text', text: 'boom' }] }); - expect(vi.mocked(showError)).toHaveBeenCalledWith('TOOL_ERROR', undefined, mockApp); + expect(vi.mocked(showError)).toHaveBeenCalledWith('TOOL_ERROR', 'boom', mockApp); expect(vi.mocked(renderDeleteWorkbookConfirm)).not.toHaveBeenCalled(); }); diff --git a/src/web/apps/src/lib/handleConfirmResult.ts b/src/web/apps/src/lib/handleConfirmResult.ts index 49f65b840..cc76a04f8 100644 --- a/src/web/apps/src/lib/handleConfirmResult.ts +++ b/src/web/apps/src/lib/handleConfirmResult.ts @@ -1,6 +1,7 @@ import type { App } from '@modelcontextprotocol/ext-apps'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { extractToolErrorMessage } from '../../../../utils/extractToolErrorMessage.js'; import { isDeleteDatasourceConfirmResult, renderDeleteDatasourceConfirm, @@ -29,7 +30,8 @@ import { */ export function handleConfirmResult(app: App, result: CallToolResult): void { if (!result || result.isError) { - showError('TOOL_ERROR', undefined, app); + const cause = result ? extractToolErrorMessage(result) : undefined; + showError('TOOL_ERROR', cause, app); return; } From 2adb821942a73067a56092cabf14a6236c467276 Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Tue, 21 Jul 2026 17:27:36 -0400 Subject: [PATCH 6/9] @W-23146707: Move app-only tools into a dedicated mcp-apps tool group record-event and get-embed-token are app-only, mcp-apps-gated tools that don't belong in token-management (OAuth token/consent lifecycle). Move them into a new mcp-apps group so operators can include/exclude them independently. --- src/tools/web/toolName.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/tools/web/toolName.ts b/src/tools/web/toolName.ts index 53a26c267..2b42ae1bb 100644 --- a/src/tools/web/toolName.ts +++ b/src/tools/web/toolName.ts @@ -53,6 +53,7 @@ export const webToolGroupNames = [ 'jobs', 'users', 'token-management', + 'mcp-apps', 'admin-insights', 'content', ] as const; @@ -95,7 +96,8 @@ export const webToolGroups = { ], jobs: ['list-jobs'], users: ['list-users', 'update-user'], - 'token-management': ['get-embed-token', 'record-event', 'revoke-access-token', 'reset-consent'], + 'token-management': ['revoke-access-token', 'reset-consent'], + 'mcp-apps': ['get-embed-token', 'record-event'], 'admin-insights': ['query-admin-insights'], content: ['delete-content', 'confirm-delete-content'], } as const satisfies Record>; From 51482685a14f4bfc880382c11fa9480fea7ec805 Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Tue, 21 Jul 2026 17:52:43 -0400 Subject: [PATCH 7/9] @W-23146707: Validate and bound record-event telemetry input - event_type: cap at 64 chars and require SCREAMING_SNAKE_CASE, rejecting malformed values (kept a bounded string, not a hard enum, since the event-type set is intentionally app-extensible) - message: truncate to 1024 chars before forwarding so an over-long detail never fails the telemetry call --- src/tools/web/recordEvent/recordEvent.test.ts | 44 ++++++++++++++++++- src/tools/web/recordEvent/recordEvent.ts | 12 ++++- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/tools/web/recordEvent/recordEvent.test.ts b/src/tools/web/recordEvent/recordEvent.test.ts index c6cc4a796..c05f0f5c7 100644 --- a/src/tools/web/recordEvent/recordEvent.test.ts +++ b/src/tools/web/recordEvent/recordEvent.test.ts @@ -1,5 +1,6 @@ import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; import { WebMcpServer } from '../../../server.web.js'; import { Provider } from '../../../utils/provider.js'; @@ -71,13 +72,52 @@ describe('getRecordEventTool', () => { expect.objectContaining({ event_type: 'EMBED_LOAD_ERROR', message: '' }), ); }); + + it('accepts SCREAMING_SNAKE_CASE event_type values', async () => { + const schema = z.object( + await Provider.from(getRecordEventTool(new WebMcpServer()).paramsSchema), + ); + for (const event_type of [ + 'TOOL_ERROR', + 'PARSE_ERROR', + 'AUTH_ERROR', + 'EMBED_LOAD_ERROR', + 'MCP_APP_CLICKED', + ]) { + expect(schema.safeParse({ event_type }).success).toBe(true); + } + }); + + it('rejects event_type that is too long or not SCREAMING_SNAKE_CASE', async () => { + const schema = z.object( + await Provider.from(getRecordEventTool(new WebMcpServer()).paramsSchema), + ); + expect(schema.safeParse({ event_type: 'A'.repeat(65) }).success).toBe(false); // too long + expect(schema.safeParse({ event_type: 'tool_error' }).success).toBe(false); // lowercase + expect(schema.safeParse({ event_type: 'TOOL ERROR' }).success).toBe(false); // spaces + expect(schema.safeParse({ event_type: '1TOOL_ERROR' }).success).toBe(false); // leading digit + expect(schema.safeParse({ event_type: '_TOOL_ERROR' }).success).toBe(false); // leading underscore + }); + + it('truncates message longer than 1024 characters in the forwarded event', async () => { + const extra = getMockRequestHandlerExtra(); + const longMessage = 'x'.repeat(2000); + await getToolResult(extra, { event_type: 'TOOL_ERROR', message: longMessage }); + + const sentMessage = sendSpy.mock.calls.find((c) => c[0] === 'tableau_mcp_event')?.[1]?.message; + expect(sentMessage).toBe('x'.repeat(1024)); + expect(sentMessage.length).toBe(1024); + }); }); async function getToolResult( extra: Extra, - args: { event_type: string; message: string | undefined }, + args: { event_type: string; message?: string | undefined }, ): Promise { const tool = getRecordEventTool(new WebMcpServer()); const callback = await Provider.from(tool.callback); - return await callback(args, extra); + // Mirror the MCP framework: params are validated/transformed against paramsSchema before the + // callback runs, so route args through the schema here (this is where message truncation happens). + const parsedArgs = z.object(await Provider.from(tool.paramsSchema)).parse(args); + return await callback({ event_type: parsedArgs.event_type, message: parsedArgs.message }, extra); } diff --git a/src/tools/web/recordEvent/recordEvent.ts b/src/tools/web/recordEvent/recordEvent.ts index 1863215fb..693a58e6b 100644 --- a/src/tools/web/recordEvent/recordEvent.ts +++ b/src/tools/web/recordEvent/recordEvent.ts @@ -9,12 +9,22 @@ import { WebTool } from '../tool.js'; // Starting field set — the final app-supplied schema is expected to grow later. const paramsSchema = { + // Bounded free-form string rather than a hard enum: the event-type set is intentionally + // app-extensible (see above), so we reject malformed values but not unknown-yet-valid ones. event_type: z .string() + .max(64) + .regex(/^[A-Z][A-Z0-9_]*$/, 'event_type must be SCREAMING_SNAKE_CASE (e.g. TOOL_ERROR).') .describe( 'The event type for product telemetry, e.g. TOOL_ERROR, PARSE_ERROR, AUTH_ERROR, EMBED_LOAD_ERROR, MCP_APP_CLICKED.', ), - message: z.string().optional().describe('Optional detail or context for the event.'), + // Optional free-text detail: truncate rather than reject so an over-long message never fails + // the telemetry call (mirrors the length cap in src/telemetry/clientDisplayName.ts). + message: z + .string() + .transform((s) => s.slice(0, 1024)) + .optional() + .describe('Optional detail or context for the event.'), }; /** From 5d4ad327b79f1cbf332933dec71340d9cabbcd36 Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Tue, 21 Jul 2026 18:27:17 -0400 Subject: [PATCH 8/9] @W-23146707: Bump patch version to 3.5.3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index cdb95d673..0e09563ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tableau/mcp-server", - "version": "3.5.2", + "version": "3.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tableau/mcp-server", - "version": "3.5.2", + "version": "3.5.3", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/ext-apps": "^1.7.2", diff --git a/package.json b/package.json index 30c6f125b..1e24d2130 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@tableau/mcp-server", "description": "Helping agents see and understand data.", - "version": "3.5.2", + "version": "3.5.3", "repository": { "type": "git", "url": "git+https://github.com/tableau/tableau-mcp.git" From fad9e477f640f39f4b546d616826a217ca8801f0 Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Tue, 21 Jul 2026 18:27:28 -0400 Subject: [PATCH 9/9] @W-23146707: Ignore .worktrees/ scratch directory --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 80e0c1837..07105a9e1 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ src/web/apps/dist/ .work/reports/*.md !.work/specs/TEMPLATE.md !.work/**/.gitkeep + +.worktrees/