From 96c76091a70cd3c05e3f125779b4af4bb981478e Mon Sep 17 00:00:00 2001 From: Andy Young Date: Tue, 23 Sep 2025 16:10:19 -0700 Subject: [PATCH 1/3] Add getServerInfo method --- src/config.ts | 25 +++++++++++++++++++++ src/sdks/tableau/apis/serverApi.ts | 17 ++++++++++++++ src/sdks/tableau/methods/serverMethods.ts | 27 +++++++++++++++++++++++ src/sdks/tableau/restApi.ts | 11 +++++++++ src/sdks/tableau/types/serverInfo.ts | 12 ++++++++++ 5 files changed, 92 insertions(+) create mode 100644 src/sdks/tableau/apis/serverApi.ts create mode 100644 src/sdks/tableau/methods/serverMethods.ts create mode 100644 src/sdks/tableau/types/serverInfo.ts diff --git a/src/config.ts b/src/config.ts index c03cb114..20aa4e74 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,13 +1,20 @@ +import { ZodiosError } from '@zodios/core'; import { CorsOptions } from 'cors'; +import { fromError, isZodErrorLike } from 'zod-validation-error'; +import RestApi from './sdks/tableau/restApi.js'; +import { ProductVersion } from './sdks/tableau/types/serverInfo.js'; import { isToolGroupName, isToolName, toolGroups, ToolName } from './tools/toolName.js'; import { isTransport, TransportName } from './transports.js'; +import { getExceptionMessage } from './utils/getExceptionMessage.js'; import invariant from './utils/invariant.js'; const authTypes = ['pat', 'direct-trust'] as const; type AuthType = (typeof authTypes)[number]; export class Config { + private _serverVersion: ProductVersion | undefined; + auth: AuthType; server: string; transport: TransportName; @@ -121,6 +128,24 @@ export class Config { this.connectedAppSecretValue = secretValue ?? ''; this.jwtAdditionalPayload = jwtAdditionalPayload || '{}'; } + + getServerVersion = async (): Promise => { + if (!this._serverVersion) { + const restApi = new RestApi(this.server); + try { + this._serverVersion = (await restApi.serverMethods.getServerInfo()).productVersion; + } catch (error) { + const reason = + error instanceof ZodiosError && isZodErrorLike(error.cause) + ? fromError(error.cause).toString() + : getExceptionMessage(error); + + throw new Error(`Failed to get server version: ${reason}`); + } + } + + return this._serverVersion; + }; } function validateServer(server: string): void { diff --git a/src/sdks/tableau/apis/serverApi.ts b/src/sdks/tableau/apis/serverApi.ts new file mode 100644 index 00000000..b4a9c970 --- /dev/null +++ b/src/sdks/tableau/apis/serverApi.ts @@ -0,0 +1,17 @@ +import { makeApi, makeEndpoint, ZodiosEndpointDefinitions } from '@zodios/core'; +import { z } from 'zod'; + +import { serverInfo } from '../types/serverInfo.js'; + +const getServerInfoEndpoint = makeEndpoint({ + method: 'get', + path: '/serverinfo', + alias: 'getServerInfo', + description: 'Returns the version of Tableau Server and the supported version of the REST API.', + response: z.object({ + serverInfo, + }), +}); + +const serverApi = makeApi([getServerInfoEndpoint]); +export const serverApis = [...serverApi] as const satisfies ZodiosEndpointDefinitions; diff --git a/src/sdks/tableau/methods/serverMethods.ts b/src/sdks/tableau/methods/serverMethods.ts new file mode 100644 index 00000000..e024c9d7 --- /dev/null +++ b/src/sdks/tableau/methods/serverMethods.ts @@ -0,0 +1,27 @@ +import { Zodios } from '@zodios/core'; + +import { serverApis } from '../apis/serverApi.js'; +import { ServerInfo } from '../types/serverInfo.js'; +import Methods from './methods.js'; + +/** + * Server methods of the Tableau Server REST API + * + * @export + * @class ServerMethods + * @link https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_server.htm + */ +export default class ServerMethods extends Methods { + constructor(baseUrl: string) { + super(new Zodios(baseUrl, serverApis)); + } + + /** + * Returns the version of Tableau Server and the supported version of the REST API. + * + * @link https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_server.htm#get_server_info + */ + getServerInfo = async (): Promise => { + return (await this._apiClient.getServerInfo()).serverInfo; + }; +} diff --git a/src/sdks/tableau/restApi.ts b/src/sdks/tableau/restApi.ts index a47fb1bd..58bf7d00 100644 --- a/src/sdks/tableau/restApi.ts +++ b/src/sdks/tableau/restApi.ts @@ -13,6 +13,7 @@ import AuthenticationMethods, { import DatasourcesMethods from './methods/datasourcesMethods.js'; import MetadataMethods from './methods/metadataMethods.js'; import PulseMethods from './methods/pulseMethods.js'; +import ServerMethods from './methods/serverMethods.js'; import ViewsMethods from './methods/viewsMethods.js'; import VizqlDataServiceMethods from './methods/vizqlDataServiceMethods.js'; import WorkbooksMethods from './methods/workbooksMethods.js'; @@ -35,6 +36,7 @@ export default class RestApi { private _datasourcesMethods?: DatasourcesMethods; private _metadataMethods?: MetadataMethods; private _pulseMethods?: PulseMethods; + private _serverMethods?: ServerMethods; private _vizqlDataServiceMethods?: VizqlDataServiceMethods; private _viewsMethods?: ViewsMethods; private _workbooksMethods?: WorkbooksMethods; @@ -116,6 +118,15 @@ export default class RestApi { return this._pulseMethods; } + get serverMethods(): ServerMethods { + if (!this._serverMethods) { + this._serverMethods = new ServerMethods(this._baseUrl); + this._addInterceptors(this._baseUrl, this._serverMethods.interceptors); + } + + return this._serverMethods; + } + get vizqlDataServiceMethods(): VizqlDataServiceMethods { if (!this._vizqlDataServiceMethods) { const baseUrl = `${this._host}/api/v1/vizql-data-service`; diff --git a/src/sdks/tableau/types/serverInfo.ts b/src/sdks/tableau/types/serverInfo.ts new file mode 100644 index 00000000..545ac8ca --- /dev/null +++ b/src/sdks/tableau/types/serverInfo.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const serverInfo = z.object({ + productVersion: z.object({ + value: z.string(), + build: z.string(), + }), + restApiVersion: z.string(), +}); + +export type ServerInfo = z.infer; +export type ProductVersion = ServerInfo['productVersion']; From 7283b2648f15af14669f7c61c8cce478704a0987 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Tue, 23 Sep 2025 16:10:36 -0700 Subject: [PATCH 2/3] Add versioned Provider --- src/tools/tool.ts | 23 +++++++++++++---------- src/utils/provider.ts | 11 +++++++++++ 2 files changed, 24 insertions(+), 10 deletions(-) create mode 100644 src/utils/provider.ts diff --git a/src/tools/tool.ts b/src/tools/tool.ts index f8805dc0..74137a6b 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -8,12 +8,15 @@ import { fromError, isZodErrorLike } from 'zod-validation-error'; import { getToolLogMessage, log } from '../logging/log.js'; import { Server } from '../server.js'; import { getExceptionMessage } from '../utils/getExceptionMessage.js'; +import { Provider } from '../utils/provider.js'; import { ToolName } from './toolName.js'; type ArgsValidator = Args extends ZodRawShape ? (args: z.objectOutputType) => void : never; +type TypeOrProvider = T | Provider; + /** * The parameters for creating a tool instance * @@ -27,19 +30,19 @@ export type ToolParams = { name: ToolName; // The description of the tool - description: string; + description: TypeOrProvider; // The schema of the tool's parameters - paramsSchema: Args; + paramsSchema: TypeOrProvider; // The annotations of the tool - annotations: ToolAnnotations; + annotations: TypeOrProvider; // A function that validates the tool's arguments provided by the client - argsValidator?: ArgsValidator; + argsValidator?: TypeOrProvider>; // The implementation of the tool itself - callback: ToolCallback; + callback: TypeOrProvider>; }; /** @@ -92,11 +95,11 @@ export class Tool { }: ToolParams) { this.server = server; this.name = name; - this.description = description; - this.paramsSchema = paramsSchema; - this.annotations = annotations; - this.argsValidator = argsValidator; - this.callback = callback; + this.description = description instanceof Provider ? description.get() : description; + this.paramsSchema = paramsSchema instanceof Provider ? paramsSchema.get() : paramsSchema; + this.annotations = annotations instanceof Provider ? annotations.get() : annotations; + this.argsValidator = argsValidator instanceof Provider ? argsValidator.get() : argsValidator; + this.callback = callback instanceof Provider ? callback.get() : callback; } logInvocation({ requestId, args }: { requestId: RequestId; args: unknown }): void { diff --git a/src/utils/provider.ts b/src/utils/provider.ts new file mode 100644 index 00000000..1fd4bcaa --- /dev/null +++ b/src/utils/provider.ts @@ -0,0 +1,11 @@ +export class Provider { + private readonly _provider: () => T; + + constructor(provider: () => T) { + this._provider = provider; + } + + get(): T { + return this._provider(); + } +} From 7514a968c3f47c56a4f70dcf2fd25d418bbfca9d Mon Sep 17 00:00:00 2001 From: Andy Young Date: Tue, 23 Sep 2025 17:23:27 -0700 Subject: [PATCH 3/3] Dynamically choose tool description based off Tableau version --- src/config.ts | 12 +- src/index.ts | 2 +- src/restApiInstance.test.ts | 9 +- src/restApiInstance.ts | 2 +- src/sdks/tableau/restApi.ts | 2 +- src/server.test.ts | 15 +- src/server.ts | 9 +- src/server/express.ts | 2 +- src/testSetup.ts | 15 + src/testShared.ts | 6 + .../queryDatasource/descriptions/2025.3.ts | 1 + .../queryDatasource/descriptions/default.ts | 310 +++++++++++++++++ .../queryDatasource/queryDatasource.test.ts | 15 +- src/tools/queryDatasource/queryDatasource.ts | 11 +- src/tools/queryDatasource/queryDescription.ts | 314 +----------------- src/utils/isTableauVersionAtLeast.ts | 33 ++ 16 files changed, 413 insertions(+), 345 deletions(-) create mode 100644 src/testShared.ts create mode 100644 src/tools/queryDatasource/descriptions/2025.3.ts create mode 100644 src/tools/queryDatasource/descriptions/default.ts create mode 100644 src/utils/isTableauVersionAtLeast.ts diff --git a/src/config.ts b/src/config.ts index 20aa4e74..66f3d4b3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,7 +2,7 @@ import { ZodiosError } from '@zodios/core'; import { CorsOptions } from 'cors'; import { fromError, isZodErrorLike } from 'zod-validation-error'; -import RestApi from './sdks/tableau/restApi.js'; +import { RestApi } from './sdks/tableau/restApi.js'; import { ProductVersion } from './sdks/tableau/types/serverInfo.js'; import { isToolGroupName, isToolName, toolGroups, ToolName } from './tools/toolName.js'; import { isTransport, TransportName } from './transports.js'; @@ -13,7 +13,7 @@ const authTypes = ['pat', 'direct-trust'] as const; type AuthType = (typeof authTypes)[number]; export class Config { - private _serverVersion: ProductVersion | undefined; + private static _serverVersion: ProductVersion | undefined; auth: AuthType; server: string; @@ -129,11 +129,11 @@ export class Config { this.jwtAdditionalPayload = jwtAdditionalPayload || '{}'; } - getServerVersion = async (): Promise => { - if (!this._serverVersion) { + getTableauServerVersion = async (): Promise => { + if (!Config._serverVersion) { const restApi = new RestApi(this.server); try { - this._serverVersion = (await restApi.serverMethods.getServerInfo()).productVersion; + Config._serverVersion = (await restApi.serverMethods.getServerInfo()).productVersion; } catch (error) { const reason = error instanceof ZodiosError && isZodErrorLike(error.cause) @@ -144,7 +144,7 @@ export class Config { } } - return this._serverVersion; + return Config._serverVersion; }; } diff --git a/src/index.ts b/src/index.ts index 5e0bf53a..43f44d95 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,7 @@ async function startServer(): Promise { switch (config.transport) { case 'stdio': { const server = new Server(); - server.registerTools(); + await server.registerTools(); server.registerRequestHandlers(); const transport = new StdioServerTransport(); diff --git a/src/restApiInstance.test.ts b/src/restApiInstance.test.ts index 596ef6e7..c30c6415 100644 --- a/src/restApiInstance.test.ts +++ b/src/restApiInstance.test.ts @@ -10,16 +10,9 @@ import { useRestApi, } from './restApiInstance.js'; import { AuthConfig } from './sdks/tableau/authConfig.js'; -import RestApi from './sdks/tableau/restApi.js'; +import { RestApi } from './sdks/tableau/restApi.js'; import { Server } from './server.js'; -vi.mock('./sdks/tableau/restApi.js', () => ({ - default: vi.fn().mockImplementation(() => ({ - signIn: vi.fn().mockResolvedValue(undefined), - signOut: vi.fn().mockResolvedValue(undefined), - })), -})); - vi.mock('./logging/log.js', () => ({ log: { info: vi.fn(), diff --git a/src/restApiInstance.ts b/src/restApiInstance.ts index 192a562b..4fb9b9b8 100644 --- a/src/restApiInstance.ts +++ b/src/restApiInstance.ts @@ -13,7 +13,7 @@ import { ResponseInterceptor, ResponseInterceptorConfig, } from './sdks/tableau/interceptors.js'; -import RestApi from './sdks/tableau/restApi.js'; +import { RestApi } from './sdks/tableau/restApi.js'; import { Server } from './server.js'; import { getExceptionMessage } from './utils/getExceptionMessage.js'; import { isAxiosError } from './utils/isAxiosError.js'; diff --git a/src/sdks/tableau/restApi.ts b/src/sdks/tableau/restApi.ts index 58bf7d00..ce298072 100644 --- a/src/sdks/tableau/restApi.ts +++ b/src/sdks/tableau/restApi.ts @@ -25,7 +25,7 @@ import { Credentials } from './types/credentials.js'; * @export * @class RestApi */ -export default class RestApi { +export class RestApi { private _creds?: Credentials; private readonly _host: string; private readonly _baseUrl: string; diff --git a/src/server.test.ts b/src/server.test.ts index a71ce570..adac4b58 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,6 +1,7 @@ import { ZodObject } from 'zod'; import { exportedForTesting as serverExportedForTesting } from './server.js'; +import { testProductVersion } from './testShared.js'; import { getQueryDatasourceTool } from './tools/queryDatasource/queryDatasource.js'; import { toolNames } from './tools/toolName.js'; import { toolFactories } from './tools/tools.js'; @@ -24,9 +25,9 @@ describe('server', () => { it('should register tools', async () => { const server = getServer(); - server.registerTools(); + await server.registerTools(); - const tools = toolFactories.map((tool) => tool(server)); + const tools = toolFactories.map((tool) => tool(server, testProductVersion)); for (const tool of tools) { expect(server.tool).toHaveBeenCalledWith( tool.name, @@ -41,9 +42,9 @@ describe('server', () => { it('should register tools filtered by includeTools', async () => { process.env.INCLUDE_TOOLS = 'query-datasource'; const server = getServer(); - server.registerTools(); + await server.registerTools(); - const tool = getQueryDatasourceTool(server); + const tool = getQueryDatasourceTool(server, testProductVersion); expect(server.tool).toHaveBeenCalledWith( tool.name, tool.description, @@ -56,9 +57,9 @@ describe('server', () => { it('should register tools filtered by excludeTools', async () => { process.env.EXCLUDE_TOOLS = 'query-datasource'; const server = getServer(); - server.registerTools(); + await server.registerTools(); - const tools = toolFactories.map((tool) => tool(server)); + const tools = toolFactories.map((tool) => tool(server, testProductVersion)); for (const tool of tools) { if (tool.name === 'query-datasource') { expect(server.tool).not.toHaveBeenCalledWith( @@ -93,7 +94,7 @@ describe('server', () => { ]; for (const sentence of sentences) { - expect(() => server.registerTools()).toThrow(sentence); + expect(async () => await server.registerTools()).rejects.toThrow(sentence); } }); diff --git a/src/server.ts b/src/server.ts index 9820eac2..0a336e46 100644 --- a/src/server.ts +++ b/src/server.ts @@ -33,14 +33,14 @@ export class Server extends McpServer { this.version = serverVersion; } - registerTools = (): void => { + registerTools = async (): Promise => { for (const { name, description, paramsSchema, annotations, callback, - } of this._getToolsToRegister()) { + } of await this._getToolsToRegister()) { this.tool(name, description, paramsSchema, annotations, callback); } }; @@ -52,10 +52,11 @@ export class Server extends McpServer { }); }; - private _getToolsToRegister = (): Array> => { + private _getToolsToRegister = async (): Promise>> => { const { includeTools, excludeTools } = getConfig(); + const productVersion = await getConfig().getTableauServerVersion(); - const tools = toolFactories.map((tool) => tool(this)); + const tools = toolFactories.map((tool) => tool(this, productVersion)); const toolsToRegister = tools.filter((tool) => { if (includeTools.length > 0) { return includeTools.includes(tool.name); diff --git a/src/server/express.ts b/src/server/express.ts index 97a53703..f1c6f76d 100644 --- a/src/server/express.ts +++ b/src/server/express.ts @@ -88,7 +88,7 @@ export async function startExpressServer({ server.close(); }); - server.registerTools(); + await server.registerTools(); server.registerRequestHandlers(); await server.connect(transport); diff --git a/src/testSetup.ts b/src/testSetup.ts index e23c1227..e82d2e30 100644 --- a/src/testSetup.ts +++ b/src/testSetup.ts @@ -1,3 +1,5 @@ +import { testProductVersion } from './testShared.js'; + vi.stubEnv('SERVER', 'https://my-tableau-server.com'); vi.stubEnv('SITE_NAME', 'tc25'); vi.stubEnv('PAT_NAME', 'sponge'); @@ -13,3 +15,16 @@ vi.mock('./server.js', async (importOriginal) => ({ }, })), })); + +vi.mock('./sdks/tableau/restApi.js', async (importOriginal) => ({ + ...(await importOriginal()), + RestApi: vi.fn().mockImplementation(() => ({ + signIn: vi.fn().mockResolvedValue(undefined), + signOut: vi.fn().mockResolvedValue(undefined), + serverMethods: { + getServerInfo: vi.fn().mockResolvedValue({ + productVersion: testProductVersion, + }), + }, + })), +})); diff --git a/src/testShared.ts b/src/testShared.ts new file mode 100644 index 00000000..4f58b6e6 --- /dev/null +++ b/src/testShared.ts @@ -0,0 +1,6 @@ +import { ProductVersion } from './sdks/tableau/types/serverInfo.js'; + +export const testProductVersion = { + value: '2025.3.0', + build: '20253.25.0903.0012', +} satisfies ProductVersion; diff --git a/src/tools/queryDatasource/descriptions/2025.3.ts b/src/tools/queryDatasource/descriptions/2025.3.ts new file mode 100644 index 00000000..99fd0235 --- /dev/null +++ b/src/tools/queryDatasource/descriptions/2025.3.ts @@ -0,0 +1 @@ +export const queryDatasourceToolDescription20253 = `Custom description for query-datasource tool for Tableau 2025.3`; diff --git a/src/tools/queryDatasource/descriptions/default.ts b/src/tools/queryDatasource/descriptions/default.ts new file mode 100644 index 00000000..bb4a05cc --- /dev/null +++ b/src/tools/queryDatasource/descriptions/default.ts @@ -0,0 +1,310 @@ +export const queryDatasourceToolDescription = `# Query Tableau Data Source Tool + +Executes VizQL queries against Tableau data sources to answer business questions from published data. This tool allows you to retrieve aggregated and filtered data with proper sorting and grouping. + +## Prerequisites +Before using this tool, you should: +1. Understand available fields and their types +3. Understand the data structure and field relationships + +## Best Practices + +### Data Volume Management +- **Always prefer aggregation** - Use aggregated fields (SUM, COUNT, AVG, etc.) instead of raw row-level data to reduce response size +- **Profile data before querying** - When unsure about data volume, first run a COUNT query to understand the scale: + \`\`\`json + { + "fields": [ + { + "fieldCaption": "Order ID", + "function": "COUNT", + "fieldAlias": "Total Records" + } + ] + } + \`\`\` +- **Use TOP filters for rankings** - When users ask for "top N" results, use TOP filter type to limit results at the database level +- **Apply restrictive filters** - Use SET, QUANTITATIVE, or DATE filters to reduce data volume before processing +- **Avoid row-level queries when possible** - Only retrieve individual records when specifically requested and the business need is clear + +### Field Usage Guidelines +- **Prefer existing fields** - Use fields already modeled in the data source rather than creating custom calculations +- **Use calculations sparingly** - Only create calculated fields when absolutely necessary and the calculation cannot be achieved through existing fields and aggregations +- **Validate field availability** - Always check field metadata before constructing queries + +### Query Construction +- **Group by meaningful dimensions** - Ensure grouping supports the business question being asked +- **Order results logically** - Use sortDirection and sortPriority to present data in a meaningful way +- **Use appropriate date functions** - Choose the right date aggregation (YEAR, QUARTER, MONTH, WEEK, DAY, or TRUNC_* variants) +- **Leverage filter capabilities** - Use the extensive filter options to narrow results + +## Data Profiling Strategy + +When a query might return large amounts of data, follow this profiling approach: + +**Step 1: Count total records** +\`\`\`json +{ + "fields": [ + { + "fieldCaption": "Primary_Key_Field", + "function": "COUNT", + "fieldAlias": "Total Records" + } + ] +} +\`\`\` + +**Step 2: Count by key dimensions** +\`\`\`json +{ + "fields": [ + { + "fieldCaption": "Category", + "fieldAlias": "Category" + }, + { + "fieldCaption": "Order ID", + "function": "COUNT", + "fieldAlias": "Record Count" + } + ] +} +\`\`\` + +**Step 3: Apply appropriate aggregation or filtering based on counts** + +## Filter Types and Usage + +### SET Filters +Filter by specific values: +\`\`\`json +{ + "field": {"fieldCaption": "Region"}, + "filterType": "SET", + "values": ["North", "South", "East"], + "exclude": false +} +\`\`\` + +### TOP Filters +Get top/bottom N records by a measure: +\`\`\`json +{ + "field": {"fieldCaption": "Customer Name"}, + "filterType": "TOP", + "howMany": 10, + "direction": "TOP", + "fieldToMeasure": {"fieldCaption": "Sales", "function": "SUM"} +} +\`\`\` + +### QUANTITATIVE Filters +Filter numeric ranges: +\`\`\`json +{ + "field": {"fieldCaption": "Sales"}, + "filterType": "QUANTITATIVE_NUMERICAL", + "quantitativeFilterType": "RANGE", + "min": 1000, + "max": 50000, + "includeNulls": false +} +\`\`\` + +### DATE Filters +Filter relative date periods: +\`\`\`json +{ + "field": {"fieldCaption": "Order Date"}, + "filterType": "DATE", + "periodType": "MONTHS", + "dateRangeType": "LAST" +} +\`\`\` + +## Example Queries + +### Example 1: Data Profiling Before Large Query +**Question:** "Show me all customer orders this year" + +**Step 1 - Profile the data volume:** +\`\`\`json +{ + "datasourceLuid": "abc123", + "query": { + "fields": [ + { + "fieldCaption": "Order ID", + "function": "COUNT", + "fieldAlias": "Total Orders This Year" + } + ], + "filters": [ + { + "field": {"fieldCaption": "Order Date"}, + "filterType": "DATE", + "periodType": "YEARS", + "dateRangeType": "CURRENT" + } + ] + } +} +\`\`\` + +**If count is manageable (< 10,000), proceed with detail query. If large, suggest aggregation:** +\`\`\`json +{ + "datasourceLuid": "abc123", + "query": { + "fields": [ + { + "fieldCaption": "Customer Name" + }, + { + "fieldCaption": "Order Date", + "function": "TRUNC_MONTH", + "sortDirection": "DESC", + "sortPriority": 1 + }, + { + "fieldCaption": "Sales", + "function": "SUM", + "fieldAlias": "Monthly Sales" + } + ], + "filters": [ + { + "field": {"fieldCaption": "Order Date"}, + "filterType": "DATE", + "periodType": "YEARS", + "dateRangeType": "CURRENT" + } + ] + } +} +\`\`\` + +### Example 2: Top Customers Query (Using TOP Filter) +**Question:** "Who are our top 10 customers by revenue?" + +\`\`\`json +{ + "datasourceLuid": "abc123", + "query": { + "fields": [ + { + "fieldCaption": "Customer Name" + }, + { + "fieldCaption": "Sales", + "function": "SUM", + "fieldAlias": "Total Revenue", + "sortDirection": "DESC", + "sortPriority": 1 + } + ], + "filters": [ + { + "field": {"fieldCaption": "Customer Name"}, + "filterType": "TOP", + "howMany": 10, + "direction": "TOP", + "fieldToMeasure": {"fieldCaption": "Sales", "function": "SUM"} + } + ] + } +} +\`\`\` + +### Example 3: Time Series with Aggregation +**Question:** "What are our monthly sales trends?" + +\`\`\`json +{ + "datasourceLuid": "abc123", + "query": { + "fields": [ + { + "fieldCaption": "Order Date", + "function": "TRUNC_MONTH", + "fieldAlias": "Month", + "sortDirection": "ASC", + "sortPriority": 1 + }, + { + "fieldCaption": "Sales", + "function": "SUM", + "fieldAlias": "Monthly Sales" + }, + { + "fieldCaption": "Order ID", + "function": "COUNT", + "fieldAlias": "Order Count" + } + ] + } +} +\`\`\` + +### Example 4: Filtered Category Analysis +**Question:** "What's the performance by product category for high-value orders?" + +\`\`\`json +{ + "datasourceLuid": "abc123", + "query": { + "fields": [ + { + "fieldCaption": "Category" + }, + { + "fieldCaption": "Sales", + "function": "SUM", + "fieldAlias": "Total Sales" + }, + { + "fieldCaption": "Sales", + "function": "AVG", + "fieldAlias": "Average Order Value", + "maxDecimalPlaces": 2 + }, + { + "fieldCaption": "Order ID", + "function": "COUNT", + "fieldAlias": "Order Count" + } + ], + "filters": [ + { + "field": {"fieldCaption": "Sales"}, + "filterType": "QUANTITATIVE_NUMERICAL", + "quantitativeFilterType": "MIN", + "min": 500 + } + ] + } +} +\`\`\` + +## Error Prevention and Data Management + +**When to profile data first:** +- User asks for "all records" or similar broad requests +- Query involves high-cardinality fields without filters +- Request could potentially return row-level data for large tables + +**Suggest aggregation when:** +- Profile queries return very high counts (> 10,000 records) +- User asks questions that can be answered with summaries +- Performance or response size might be an issue + +**Don't call this tool if:** +- The requested fields are not available in the data source +- The question requires data not present in the current data source +- Field validation shows incompatible field types for the requested operation + +**Instead:** +- Use metadata tools to understand available fields +- Suggest alternative questions that can be answered with available data +- Recommend appropriate aggregation levels for the business question`; diff --git a/src/tools/queryDatasource/queryDatasource.test.ts b/src/tools/queryDatasource/queryDatasource.test.ts index 55e8fda1..d2e24567 100644 --- a/src/tools/queryDatasource/queryDatasource.test.ts +++ b/src/tools/queryDatasource/queryDatasource.test.ts @@ -4,6 +4,7 @@ import { Err, Ok } from 'ts-results-es'; import { QueryOutput } from '../../sdks/tableau/apis/vizqlDataServiceApi.js'; import { Server } from '../../server.js'; +import { testProductVersion } from '../../testShared.js'; import { getVizqlDataServiceDisabledError } from '../getVizqlDataServiceDisabledError.js'; import { exportedForTesting as datasourceCredentialsExportedForTesting } from './datasourceCredentials.js'; import { getQueryDatasourceTool } from './queryDatasource.js'; @@ -71,7 +72,7 @@ describe('queryDatasourceTool', () => { }); it('should create a tool instance with correct properties', () => { - const queryDatasourceTool = getQueryDatasourceTool(new Server()); + const queryDatasourceTool = getQueryDatasourceTool(new Server(), testProductVersion); expect(queryDatasourceTool.name).toBe('query-datasource'); expect(queryDatasourceTool.description).toEqual(expect.any(String)); expect(queryDatasourceTool.paramsSchema).not.toBeUndefined(); @@ -239,7 +240,7 @@ describe('queryDatasourceTool', () => { ], }), ); - const queryDatasourceTool = getQueryDatasourceTool(new Server()); + const queryDatasourceTool = getQueryDatasourceTool(new Server(), testProductVersion); const result = await queryDatasourceTool.callback( { datasourceLuid: 'test-datasource-luid', @@ -288,7 +289,7 @@ describe('queryDatasourceTool', () => { ], }), ); - const queryDatasourceTool = getQueryDatasourceTool(new Server()); + const queryDatasourceTool = getQueryDatasourceTool(new Server(), testProductVersion); const result = await queryDatasourceTool.callback( { datasourceLuid: 'test-datasource-luid', @@ -330,7 +331,7 @@ describe('queryDatasourceTool', () => { // Mock main query only mocks.mockQueryDatasource.mockResolvedValueOnce(new Ok(mockMainQueryResult)); - const queryDatasourceTool = getQueryDatasourceTool(new Server()); + const queryDatasourceTool = getQueryDatasourceTool(new Server(), testProductVersion); const result = await queryDatasourceTool.callback( { datasourceLuid: 'test-datasource-luid', @@ -371,7 +372,7 @@ describe('queryDatasourceTool', () => { // Mock main query only mocks.mockQueryDatasource.mockResolvedValueOnce(new Ok(mockMainQueryResult)); - const queryDatasourceTool = getQueryDatasourceTool(new Server()); + const queryDatasourceTool = getQueryDatasourceTool(new Server(), testProductVersion); const result = await queryDatasourceTool.callback( { datasourceLuid: 'test-datasource-luid', @@ -427,7 +428,7 @@ describe('queryDatasourceTool', () => { }), ); - const queryDatasourceTool = getQueryDatasourceTool(new Server()); + const queryDatasourceTool = getQueryDatasourceTool(new Server(), testProductVersion); const result = await queryDatasourceTool.callback( { datasourceLuid: 'test-datasource-luid', @@ -477,7 +478,7 @@ describe('queryDatasourceTool', () => { }); async function getToolResult(): Promise { - const queryDatasourceTool = getQueryDatasourceTool(new Server()); + const queryDatasourceTool = getQueryDatasourceTool(new Server(), testProductVersion); return await queryDatasourceTool.callback( { datasourceLuid: '71db762b-6201-466b-93da-57cc0aec8ed9', diff --git a/src/tools/queryDatasource/queryDatasource.ts b/src/tools/queryDatasource/queryDatasource.ts index 40dcbfae..16778762 100644 --- a/src/tools/queryDatasource/queryDatasource.ts +++ b/src/tools/queryDatasource/queryDatasource.ts @@ -11,13 +11,15 @@ import { QueryOutput, TableauError, } from '../../sdks/tableau/apis/vizqlDataServiceApi.js'; +import { ProductVersion } from '../../sdks/tableau/types/serverInfo.js'; import { Server } from '../../server.js'; +import { Provider } from '../../utils/provider.js'; import { getVizqlDataServiceDisabledError } from '../getVizqlDataServiceDisabledError.js'; import { Tool } from '../tool.js'; import { getDatasourceCredentials } from './datasourceCredentials.js'; import { handleQueryDatasourceError } from './queryDatasourceErrorHandler.js'; import { validateQuery } from './queryDatasourceValidator.js'; -import { queryDatasourceToolDescription } from './queryDescription.js'; +import { getQueryDatasourceToolDescription } from './queryDescription.js'; import { validateFilterValues } from './validators/validateFilterValues.js'; type Datasource = z.infer; @@ -40,11 +42,14 @@ export type QueryDatasourceError = error: z.infer; }; -export const getQueryDatasourceTool = (server: Server): Tool => { +export const getQueryDatasourceTool = ( + server: Server, + productVersion: ProductVersion, +): Tool => { const queryDatasourceTool = new Tool({ server, name: 'query-datasource', - description: queryDatasourceToolDescription, + description: new Provider(() => getQueryDatasourceToolDescription(productVersion)), paramsSchema, annotations: { title: 'Query Datasource', diff --git a/src/tools/queryDatasource/queryDescription.ts b/src/tools/queryDatasource/queryDescription.ts index bb4a05cc..3b57624c 100644 --- a/src/tools/queryDatasource/queryDescription.ts +++ b/src/tools/queryDatasource/queryDescription.ts @@ -1,310 +1,12 @@ -export const queryDatasourceToolDescription = `# Query Tableau Data Source Tool +import { ProductVersion } from '../../sdks/tableau/types/serverInfo.js'; +import { isTableauVersionAtLeast } from '../../utils/isTableauVersionAtLeast.js'; +import { queryDatasourceToolDescription20253 } from './descriptions/2025.3.js'; +import { queryDatasourceToolDescription } from './descriptions/default.js'; -Executes VizQL queries against Tableau data sources to answer business questions from published data. This tool allows you to retrieve aggregated and filtered data with proper sorting and grouping. - -## Prerequisites -Before using this tool, you should: -1. Understand available fields and their types -3. Understand the data structure and field relationships - -## Best Practices - -### Data Volume Management -- **Always prefer aggregation** - Use aggregated fields (SUM, COUNT, AVG, etc.) instead of raw row-level data to reduce response size -- **Profile data before querying** - When unsure about data volume, first run a COUNT query to understand the scale: - \`\`\`json - { - "fields": [ - { - "fieldCaption": "Order ID", - "function": "COUNT", - "fieldAlias": "Total Records" - } - ] - } - \`\`\` -- **Use TOP filters for rankings** - When users ask for "top N" results, use TOP filter type to limit results at the database level -- **Apply restrictive filters** - Use SET, QUANTITATIVE, or DATE filters to reduce data volume before processing -- **Avoid row-level queries when possible** - Only retrieve individual records when specifically requested and the business need is clear - -### Field Usage Guidelines -- **Prefer existing fields** - Use fields already modeled in the data source rather than creating custom calculations -- **Use calculations sparingly** - Only create calculated fields when absolutely necessary and the calculation cannot be achieved through existing fields and aggregations -- **Validate field availability** - Always check field metadata before constructing queries - -### Query Construction -- **Group by meaningful dimensions** - Ensure grouping supports the business question being asked -- **Order results logically** - Use sortDirection and sortPriority to present data in a meaningful way -- **Use appropriate date functions** - Choose the right date aggregation (YEAR, QUARTER, MONTH, WEEK, DAY, or TRUNC_* variants) -- **Leverage filter capabilities** - Use the extensive filter options to narrow results - -## Data Profiling Strategy - -When a query might return large amounts of data, follow this profiling approach: - -**Step 1: Count total records** -\`\`\`json -{ - "fields": [ - { - "fieldCaption": "Primary_Key_Field", - "function": "COUNT", - "fieldAlias": "Total Records" - } - ] -} -\`\`\` - -**Step 2: Count by key dimensions** -\`\`\`json -{ - "fields": [ - { - "fieldCaption": "Category", - "fieldAlias": "Category" - }, - { - "fieldCaption": "Order ID", - "function": "COUNT", - "fieldAlias": "Record Count" - } - ] -} -\`\`\` - -**Step 3: Apply appropriate aggregation or filtering based on counts** - -## Filter Types and Usage - -### SET Filters -Filter by specific values: -\`\`\`json -{ - "field": {"fieldCaption": "Region"}, - "filterType": "SET", - "values": ["North", "South", "East"], - "exclude": false -} -\`\`\` - -### TOP Filters -Get top/bottom N records by a measure: -\`\`\`json -{ - "field": {"fieldCaption": "Customer Name"}, - "filterType": "TOP", - "howMany": 10, - "direction": "TOP", - "fieldToMeasure": {"fieldCaption": "Sales", "function": "SUM"} -} -\`\`\` - -### QUANTITATIVE Filters -Filter numeric ranges: -\`\`\`json -{ - "field": {"fieldCaption": "Sales"}, - "filterType": "QUANTITATIVE_NUMERICAL", - "quantitativeFilterType": "RANGE", - "min": 1000, - "max": 50000, - "includeNulls": false -} -\`\`\` - -### DATE Filters -Filter relative date periods: -\`\`\`json -{ - "field": {"fieldCaption": "Order Date"}, - "filterType": "DATE", - "periodType": "MONTHS", - "dateRangeType": "LAST" -} -\`\`\` - -## Example Queries - -### Example 1: Data Profiling Before Large Query -**Question:** "Show me all customer orders this year" - -**Step 1 - Profile the data volume:** -\`\`\`json -{ - "datasourceLuid": "abc123", - "query": { - "fields": [ - { - "fieldCaption": "Order ID", - "function": "COUNT", - "fieldAlias": "Total Orders This Year" - } - ], - "filters": [ - { - "field": {"fieldCaption": "Order Date"}, - "filterType": "DATE", - "periodType": "YEARS", - "dateRangeType": "CURRENT" - } - ] - } -} -\`\`\` - -**If count is manageable (< 10,000), proceed with detail query. If large, suggest aggregation:** -\`\`\`json -{ - "datasourceLuid": "abc123", - "query": { - "fields": [ - { - "fieldCaption": "Customer Name" - }, - { - "fieldCaption": "Order Date", - "function": "TRUNC_MONTH", - "sortDirection": "DESC", - "sortPriority": 1 - }, - { - "fieldCaption": "Sales", - "function": "SUM", - "fieldAlias": "Monthly Sales" - } - ], - "filters": [ - { - "field": {"fieldCaption": "Order Date"}, - "filterType": "DATE", - "periodType": "YEARS", - "dateRangeType": "CURRENT" - } - ] - } -} -\`\`\` - -### Example 2: Top Customers Query (Using TOP Filter) -**Question:** "Who are our top 10 customers by revenue?" - -\`\`\`json -{ - "datasourceLuid": "abc123", - "query": { - "fields": [ - { - "fieldCaption": "Customer Name" - }, - { - "fieldCaption": "Sales", - "function": "SUM", - "fieldAlias": "Total Revenue", - "sortDirection": "DESC", - "sortPriority": 1 - } - ], - "filters": [ - { - "field": {"fieldCaption": "Customer Name"}, - "filterType": "TOP", - "howMany": 10, - "direction": "TOP", - "fieldToMeasure": {"fieldCaption": "Sales", "function": "SUM"} - } - ] - } -} -\`\`\` - -### Example 3: Time Series with Aggregation -**Question:** "What are our monthly sales trends?" - -\`\`\`json -{ - "datasourceLuid": "abc123", - "query": { - "fields": [ - { - "fieldCaption": "Order Date", - "function": "TRUNC_MONTH", - "fieldAlias": "Month", - "sortDirection": "ASC", - "sortPriority": 1 - }, - { - "fieldCaption": "Sales", - "function": "SUM", - "fieldAlias": "Monthly Sales" - }, - { - "fieldCaption": "Order ID", - "function": "COUNT", - "fieldAlias": "Order Count" - } - ] +export function getQueryDatasourceToolDescription(productVersion: ProductVersion): string { + if (isTableauVersionAtLeast({ productVersion, minVersion: '2025.3.0' })) { + return queryDatasourceToolDescription20253; } -} -\`\`\` - -### Example 4: Filtered Category Analysis -**Question:** "What's the performance by product category for high-value orders?" -\`\`\`json -{ - "datasourceLuid": "abc123", - "query": { - "fields": [ - { - "fieldCaption": "Category" - }, - { - "fieldCaption": "Sales", - "function": "SUM", - "fieldAlias": "Total Sales" - }, - { - "fieldCaption": "Sales", - "function": "AVG", - "fieldAlias": "Average Order Value", - "maxDecimalPlaces": 2 - }, - { - "fieldCaption": "Order ID", - "function": "COUNT", - "fieldAlias": "Order Count" - } - ], - "filters": [ - { - "field": {"fieldCaption": "Sales"}, - "filterType": "QUANTITATIVE_NUMERICAL", - "quantitativeFilterType": "MIN", - "min": 500 - } - ] - } + return queryDatasourceToolDescription; } -\`\`\` - -## Error Prevention and Data Management - -**When to profile data first:** -- User asks for "all records" or similar broad requests -- Query involves high-cardinality fields without filters -- Request could potentially return row-level data for large tables - -**Suggest aggregation when:** -- Profile queries return very high counts (> 10,000 records) -- User asks questions that can be answered with summaries -- Performance or response size might be an issue - -**Don't call this tool if:** -- The requested fields are not available in the data source -- The question requires data not present in the current data source -- Field validation shows incompatible field types for the requested operation - -**Instead:** -- Use metadata tools to understand available fields -- Suggest alternative questions that can be answered with available data -- Recommend appropriate aggregation levels for the business question`; diff --git a/src/utils/isTableauVersionAtLeast.ts b/src/utils/isTableauVersionAtLeast.ts new file mode 100644 index 00000000..e9093de9 --- /dev/null +++ b/src/utils/isTableauVersionAtLeast.ts @@ -0,0 +1,33 @@ +import { ProductVersion } from '../sdks/tableau/types/serverInfo.js'; + +export function isTableauVersionAtLeast({ + productVersion, + minVersion, +}: { + productVersion: ProductVersion; + minVersion: `${number}.${number}.${number}`; +}): boolean { + const { value: versionValue } = productVersion; + + if (versionValue === 'main') { + // Build is from the main branch, so the build version is on the "build" attribute and looks like "main.25.0804.1416" + // This is likely an internal dev environment, so we'll assume it's a fresh build and pass the check. + return true; + } + + // Build is from a release branch, so the release version is the value and looks like "2025.3.0" + const [year, major, minor] = versionValue.split('.').map(Number); + const [minYear, minMajor, minMinor] = minVersion.split('.').map(Number); + + if ( + year > minYear || + (year === minYear && major > minMajor) || + (year === minYear && major === minMajor && minor >= minMinor) + ) { + // Tableau version is newer than the minimum required version + return true; + } + + // Tableau version is older than the minimum required version + return false; +}