diff --git a/docs/docs/tools/data-qna/get-datasource-metadata.md b/docs/docs/tools/data-qna/get-datasource-metadata.md index b1ad666a..4dd55b5d 100644 --- a/docs/docs/tools/data-qna/get-datasource-metadata.md +++ b/docs/docs/tools/data-qna/get-datasource-metadata.md @@ -32,6 +32,7 @@ Example: `2d935df8-fe7e-4fd8-bb14-35eb4ba31d45` { "name": "Returned", "dataType": "STRING", + "columnClass": "COLUMN", "defaultAggregation": "COUNT", "dataCategory": "NOMINAL", "role": "DIMENSION" @@ -39,6 +40,7 @@ Example: `2d935df8-fe7e-4fd8-bb14-35eb4ba31d45` { "name": "Category", "dataType": "STRING", + "columnClass": "COLUMN", "defaultAggregation": "COUNT", "dataCategory": "NOMINAL", "role": "DIMENSION" @@ -46,6 +48,7 @@ Example: `2d935df8-fe7e-4fd8-bb14-35eb4ba31d45` { "name": "Discount", "dataType": "REAL", + "columnClass": "COLUMN", "defaultAggregation": "SUM", "dataCategory": "QUANTITATIVE", "role": "MEASURE" @@ -53,6 +56,7 @@ Example: `2d935df8-fe7e-4fd8-bb14-35eb4ba31d45` { "name": "Postal Code", "dataType": "STRING", + "columnClass": "COLUMN", "defaultAggregation": "COUNT", "dataCategory": "ORDINAL", "role": "DIMENSION", @@ -66,6 +70,7 @@ Example: `2d935df8-fe7e-4fd8-bb14-35eb4ba31d45` { "name": "Order ID", "dataType": "STRING", + "columnClass": "COLUMN", "defaultAggregation": "COUNT", "dataCategory": "NOMINAL", "role": "DIMENSION" @@ -73,6 +78,7 @@ Example: `2d935df8-fe7e-4fd8-bb14-35eb4ba31d45` { "name": "Product Name", "dataType": "STRING", + "columnClass": "COLUMN", "defaultAggregation": "COUNT", "dataCategory": "NOMINAL", "role": "DIMENSION" @@ -80,6 +86,7 @@ Example: `2d935df8-fe7e-4fd8-bb14-35eb4ba31d45` { "name": "Ship Date", "dataType": "DATE", + "columnClass": "COLUMN", "defaultAggregation": "YEAR", "dataCategory": "ORDINAL", "role": "DIMENSION" @@ -87,6 +94,7 @@ Example: `2d935df8-fe7e-4fd8-bb14-35eb4ba31d45` { "name": "Quantity", "dataType": "INTEGER", + "columnClass": "COLUMN", "defaultAggregation": "SUM", "dataCategory": "QUANTITATIVE", "role": "MEASURE" @@ -94,6 +102,7 @@ Example: `2d935df8-fe7e-4fd8-bb14-35eb4ba31d45` { "name": "City", "dataType": "STRING", + "columnClass": "COLUMN", "defaultAggregation": "COUNT", "dataCategory": "NOMINAL", "role": "DIMENSION" @@ -101,6 +110,7 @@ Example: `2d935df8-fe7e-4fd8-bb14-35eb4ba31d45` { "name": "Sub-Category", "dataType": "STRING", + "columnClass": "COLUMN", "defaultAggregation": "COUNT", "dataCategory": "NOMINAL", "role": "DIMENSION" @@ -108,14 +118,16 @@ Example: `2d935df8-fe7e-4fd8-bb14-35eb4ba31d45` { "name": "Profit (bin)", "dataType": "INTEGER", + "columnClass": "BIN", "defaultAggregation": "NONE", + "formula": "[Profit]", "dataCategory": "ORDINAL", - "role": "DIMENSION", - "formula": "[Profit]" + "role": "DIMENSION" }, { "name": "Segment", "dataType": "STRING", + "columnClass": "COLUMN", "defaultAggregation": "COUNT", "dataCategory": "NOMINAL", "role": "DIMENSION" @@ -123,6 +135,7 @@ Example: `2d935df8-fe7e-4fd8-bb14-35eb4ba31d45` { "name": "State/Province", "dataType": "STRING", + "columnClass": "COLUMN", "defaultAggregation": "COUNT", "dataCategory": "NOMINAL", "role": "DIMENSION" @@ -130,6 +143,7 @@ Example: `2d935df8-fe7e-4fd8-bb14-35eb4ba31d45` { "name": "Sales", "dataType": "REAL", + "columnClass": "COLUMN", "defaultAggregation": "SUM", "dataCategory": "QUANTITATIVE", "role": "MEASURE" @@ -137,6 +151,7 @@ Example: `2d935df8-fe7e-4fd8-bb14-35eb4ba31d45` { "name": "Manufacturer", "dataType": "STRING", + "columnClass": "GROUP", "defaultAggregation": "COUNT", "dataCategory": "NOMINAL", "role": "DIMENSION" @@ -144,6 +159,7 @@ Example: `2d935df8-fe7e-4fd8-bb14-35eb4ba31d45` { "name": "Region", "dataType": "STRING", + "columnClass": "COLUMN", "defaultAggregation": "COUNT", "dataCategory": "NOMINAL", "role": "DIMENSION" @@ -151,6 +167,7 @@ Example: `2d935df8-fe7e-4fd8-bb14-35eb4ba31d45` { "name": "Ship Mode", "dataType": "STRING", + "columnClass": "COLUMN", "defaultAggregation": "COUNT", "dataCategory": "NOMINAL", "role": "DIMENSION" @@ -158,6 +175,7 @@ Example: `2d935df8-fe7e-4fd8-bb14-35eb4ba31d45` { "name": "Order Date", "dataType": "DATE", + "columnClass": "COLUMN", "defaultAggregation": "YEAR", "dataCategory": "ORDINAL", "role": "DIMENSION" @@ -165,6 +183,7 @@ Example: `2d935df8-fe7e-4fd8-bb14-35eb4ba31d45` { "name": "Profit", "dataType": "REAL", + "columnClass": "COLUMN", "defaultAggregation": "SUM", "dataCategory": "QUANTITATIVE", "role": "MEASURE" @@ -172,6 +191,7 @@ Example: `2d935df8-fe7e-4fd8-bb14-35eb4ba31d45` { "name": "Country/Region", "dataType": "STRING", + "columnClass": "COLUMN", "defaultAggregation": "COUNT", "dataCategory": "NOMINAL", "role": "DIMENSION" @@ -179,20 +199,42 @@ Example: `2d935df8-fe7e-4fd8-bb14-35eb4ba31d45` { "name": "Profit Ratio", "dataType": "REAL", + "columnClass": "CALCULATION", "defaultAggregation": "AGG", + "formula": "SUM([Profit])/SUM([Sales])", "dataCategory": "QUANTITATIVE", "role": "MEASURE", - "formula": "SUM([Profit])/SUM([Sales])", "isAutoGenerated": false, "hasUserReference": false }, { "name": "Customer Name", "dataType": "STRING", + "columnClass": "COLUMN", "defaultAggregation": "COUNT", "dataCategory": "NOMINAL", "role": "DIMENSION" } + ], + "parameters": [ + { + "name": "Top Customers", + "parameterType": "QUANTITATIVE_RANGE", + "dataType": "INTEGER", + "value": 5, + "min": 5, + "max": 20, + "step": 5 + }, + { + "name": "Profit Bin Size", + "parameterType": "QUANTITATIVE_RANGE", + "dataType": "INTEGER", + "value": 200, + "min": 50, + "max": 200, + "step": 50 + } ] } ``` diff --git a/package-lock.json b/package-lock.json index 1059efea..53fbf02a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tableau/mcp-server", - "version": "1.10.0", + "version": "1.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tableau/mcp-server", - "version": "1.10.0", + "version": "1.10.1", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", diff --git a/package.json b/package.json index 3644c822..72abde56 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@tableau/mcp-server", "description": "An MCP server for Tableau, providing a suite of tools that will make it easier for developers to build AI applications that integrate with Tableau.", - "version": "1.10.0", + "version": "1.10.1", "repository": { "type": "git", "url": "git+https://github.com/tableau/tableau-mcp.git" diff --git a/src/sdks/tableau/apis/metadataApi.ts b/src/sdks/tableau/apis/metadataApi.ts index 0b18065a..e05f85f2 100644 --- a/src/sdks/tableau/apis/metadataApi.ts +++ b/src/sdks/tableau/apis/metadataApi.ts @@ -5,47 +5,47 @@ export const graphqlResponse = z.object({ data: z.object({ publishedDatasources: z.array( z.object({ - name: z.string().or(z.null()), - description: z.string().or(z.null()), + name: z.string().nullable(), + description: z.string().nullable(), owner: z.object({ - name: z.string().or(z.null()), + name: z.string().nullable(), }), fields: z.array( z.object({ name: z.string(), - isHidden: z.boolean().or(z.null()), - description: z.string().or(z.null()), + isHidden: z.boolean().nullable(), + description: z.string().nullable(), descriptionInherited: z .array( z .object({ attribute: z.string(), - value: z.string().or(z.null()), + value: z.string().nullable(), }) - .or(z.null()), + .nullable(), ) - .or(z.null()), + .nullable(), fullyQualifiedName: z.string(), __typename: z.string(), // Common field properties (optional since they depend on field type) - dataCategory: z.string().or(z.null()).optional(), - role: z.string().or(z.null()).optional(), - dataType: z.string().or(z.null()).optional(), - defaultFormat: z.string().or(z.null()).optional(), - semanticRole: z.string().or(z.null()).optional(), - aggregation: z.string().or(z.null()).optional(), - aggregationParam: z.string().or(z.null()).optional(), + dataCategory: z.string().nullish(), + role: z.string().nullish(), + dataType: z.string().nullish(), + defaultFormat: z.string().nullish(), + semanticRole: z.string().nullish(), + aggregation: z.string().nullish(), + aggregationParam: z.string().nullish(), // CalculatedField specific properties - formula: z.string().or(z.null()).optional(), - isAutoGenerated: z.boolean().or(z.null()).optional(), - hasUserReference: z.boolean().or(z.null()).optional(), + formula: z.string().nullish(), + isAutoGenerated: z.boolean().nullish(), + hasUserReference: z.boolean().nullish(), // BinField specific properties - binSize: z.number().or(z.null()).optional(), + binSize: z.number().nullish(), // GroupField specific properties - hasOther: z.boolean().or(z.null()).optional(), + hasOther: z.boolean().nullish(), // CombinedSetField specific properties - delimiter: z.string().or(z.null()).optional(), - combinationType: z.string().or(z.null()).optional(), + delimiter: z.string().nullish(), + combinationType: z.string().nullish(), }), ), }), diff --git a/src/sdks/tableau/apis/vizqlDataServiceApi.ts b/src/sdks/tableau/apis/vizqlDataServiceApi.ts index 56d433d6..be3afcb7 100644 --- a/src/sdks/tableau/apis/vizqlDataServiceApi.ts +++ b/src/sdks/tableau/apis/vizqlDataServiceApi.ts @@ -55,29 +55,70 @@ export const Function = z.enum([ 'UNSPECIFIED', ]); +const DataType = z.enum([ + 'INTEGER', + 'REAL', + 'STRING', + 'DATETIME', + 'BOOLEAN', + 'DATE', + 'SPATIAL', + 'UNKNOWN', +]); + +const PeriodType = z.enum(['MINUTES', 'HOURS', 'DAYS', 'WEEKS', 'MONTHS', 'QUARTERS', 'YEARS']); + const FieldMetadata = z .object({ fieldName: z.string(), fieldCaption: z.string(), - dataType: z.enum([ - 'INTEGER', - 'REAL', - 'STRING', - 'DATETIME', - 'BOOLEAN', - 'DATE', - 'SPATIAL', - 'UNKNOWN', - ]), + dataType: DataType, defaultAggregation: Function, + columnClass: z.enum(['COLUMN', 'BIN', 'GROUP', 'CALCULATION', 'TABLE_CALCULATION']), + formula: z.string(), logicalTableId: z.string(), }) .partial() .passthrough(); +const ParameterValue = z.union([z.number(), z.string(), z.boolean(), z.null()]); + +const ParameterBase = z.object({ + parameterCaption: z.string(), + dataType: DataType.exclude(['DATETIME', 'SPATIAL', 'UNKNOWN']), + parameterName: z.string().optional(), + value: ParameterValue, +}); + +const Parameter = z.discriminatedUnion('parameterType', [ + ParameterBase.extend({ parameterType: z.literal('ANY_VALUE') }).strict(), + ParameterBase.extend({ + parameterType: z.literal('LIST'), + members: z.array(ParameterValue), + }).strict(), + ParameterBase.extend({ + parameterType: z.literal('QUANTITATIVE_DATE'), + value: z.string().nullable(), + minDate: z.string().nullish(), + maxDate: z.string().nullish(), + periodValue: z.number().nullish(), + periodType: PeriodType.nullish(), + }).strict(), + ParameterBase.extend({ + parameterType: z.literal('QUANTITATIVE_RANGE'), + value: z.number().nullable(), + min: z.number().nullish(), + max: z.number().nullish(), + step: z.number().nullish(), + }).strict(), +]); + export const MetadataOutput = z .object({ data: z.array(FieldMetadata), + extraData: z.object({ + parameters: z.array(Parameter), + }), }) .partial() .passthrough(); @@ -138,7 +179,7 @@ export const SetFilter = SimpleFilterBase.extend({ const RelativeDateFilterBase = SimpleFilterBase.extend({ filterType: z.literal('DATE'), - periodType: z.enum(['MINUTES', 'HOURS', 'DAYS', 'WEEKS', 'MONTHS', 'QUARTERS', 'YEARS']), + periodType: PeriodType, anchorDate: z.string().optional(), includeNulls: z.boolean().optional(), }); diff --git a/src/tools/getDatasourceMetadata/datasourceMetadataUtils.ts b/src/tools/getDatasourceMetadata/datasourceMetadataUtils.ts index abf35345..52d8ed84 100644 --- a/src/tools/getDatasourceMetadata/datasourceMetadataUtils.ts +++ b/src/tools/getDatasourceMetadata/datasourceMetadataUtils.ts @@ -6,35 +6,56 @@ import { MetadataResponse } from '../../sdks/tableau/apis/vizqlDataServiceApi.js export const fieldSchema = z .object({ name: z.string(), - dataType: z.string().or(z.null()), - defaultAggregation: z.string().or(z.null()), - description: z.string().or(z.null()), + columnClass: z.string(), + dataType: z.string().nullable(), + defaultAggregation: z.string().nullable(), + description: z.string().nullable(), descriptionInherited: z - .array(z.object({ attribute: z.string(), value: z.string().or(z.null()) }).or(z.null())) - .or(z.null()), - dataCategory: z.string().or(z.null()), - role: z.string().or(z.null()), - defaultFormat: z.string().or(z.null()), - semanticRole: z.string().or(z.null()), - aggregation: z.string().or(z.null()), - aggregationParam: z.string().or(z.null()), - formula: z.string().or(z.null()), - isAutoGenerated: z.boolean().or(z.null()), - hasUserReference: z.boolean().or(z.null()), - binSize: z.number().or(z.null()), + .array(z.object({ attribute: z.string(), value: z.string().nullable() }).nullable()) + .nullable(), + dataCategory: z.string().nullable(), + role: z.string().nullable(), + defaultFormat: z.string().nullable(), + semanticRole: z.string().nullable(), + aggregation: z.string().nullable(), + aggregationParam: z.string().nullable(), + formula: z.string().nullable(), + isAutoGenerated: z.boolean().nullable(), + hasUserReference: z.boolean().nullable(), + binSize: z.number().nullable(), + }) + .partial(); + +export const parameterSchema = z + .object({ + name: z.string(), + parameterType: z.string(), + dataType: z.string().nullable(), + value: z.union([z.number(), z.string(), z.boolean(), z.null()]), + members: z.array(z.union([z.number(), z.string(), z.boolean(), z.null()])), + min: z.number().nullable(), + max: z.number().nullable(), + step: z.number().nullable(), + minDate: z.string().nullable(), + maxDate: z.string().nullable(), + periodValue: z.number().nullable(), + periodType: z.string().nullable(), }) .partial(); export const fieldsResultSchema = z.object({ fields: z.array(fieldSchema), + parameters: z.array(parameterSchema), }); +type Parameter = z.infer; type Field = z.infer; export type FieldsResult = z.infer; export function simplifyReadMetadataResult(readMetadataResult: MetadataResponse): FieldsResult { const simplifiedResponse: FieldsResult = { fields: [], + parameters: [], }; if (!readMetadataResult.data) { @@ -47,15 +68,47 @@ export function simplifyReadMetadataResult(readMetadataResult: MetadataResponse) const toPush: Field = { name: field.fieldCaption, dataType: field.dataType, + columnClass: field.columnClass, }; if (field.defaultAggregation) { toPush.defaultAggregation = field.defaultAggregation; } + if (field.formula) { + toPush.formula = field.formula; + } + simplifiedResponse.fields.push(toPush); } + // Populate parameters from readMetadata results. + if (readMetadataResult.extraData?.parameters) { + for (const parameter of readMetadataResult.extraData.parameters) { + const toPush: Parameter = { + name: parameter.parameterCaption, + parameterType: parameter.parameterType, + dataType: parameter.dataType, + value: parameter.value, + }; + + if (parameter.parameterType === 'LIST' && parameter.members) { + toPush.members = parameter.members; + } else if (parameter.parameterType === 'QUANTITATIVE_DATE') { + toPush.minDate = parameter.minDate; + toPush.maxDate = parameter.maxDate; + toPush.periodValue = parameter.periodValue; + toPush.periodType = parameter.periodType; + } else if (parameter.parameterType === 'QUANTITATIVE_RANGE') { + toPush.min = parameter.min; + toPush.max = parameter.max; + toPush.step = parameter.step; + } + + simplifiedResponse.parameters.push(toPush); + } + } + return simplifiedResponse; } @@ -68,6 +121,7 @@ export function combineFields( // to optimize for LLM accuracy and reduce tokens in response. const combinedFields: FieldsResult = { fields: [], + parameters: [], }; if (!readMetadataResult.data) { @@ -95,15 +149,47 @@ export function combineFields( const toPush: Field = { name: field.fieldCaption, dataType: field.dataType, + columnClass: field.columnClass, }; if (field.defaultAggregation) { toPush.defaultAggregation = field.defaultAggregation; } + if (field.formula) { + toPush.formula = field.formula; + } + combinedFields.fields.push(toPush); } + // Populate parameters from readMetadata results. + if (readMetadataResult.extraData?.parameters) { + for (const parameter of readMetadataResult.extraData.parameters) { + const toPush: Parameter = { + name: parameter.parameterCaption, + parameterType: parameter.parameterType, + dataType: parameter.dataType, + value: parameter.value, + }; + + if (parameter.parameterType === 'LIST' && parameter.members) { + toPush.members = parameter.members; + } else if (parameter.parameterType === 'QUANTITATIVE_DATE') { + toPush.minDate = parameter.minDate; + toPush.maxDate = parameter.maxDate; + toPush.periodValue = parameter.periodValue; + toPush.periodType = parameter.periodType; + } else if (parameter.parameterType === 'QUANTITATIVE_RANGE') { + toPush.min = parameter.min; + toPush.max = parameter.max; + toPush.step = parameter.step; + } + + combinedFields.parameters.push(toPush); + } + } + if (!listFieldsResult.data.publishedDatasources[0]?.fields.length) { return combinedFields; } diff --git a/src/tools/getDatasourceMetadata/getDatasourceMetadata.test.ts b/src/tools/getDatasourceMetadata/getDatasourceMetadata.test.ts index 93dba1e4..2df323fe 100644 --- a/src/tools/getDatasourceMetadata/getDatasourceMetadata.test.ts +++ b/src/tools/getDatasourceMetadata/getDatasourceMetadata.test.ts @@ -11,15 +11,19 @@ const mockReadMetadataResponses = vi.hoisted(() => ({ { fieldName: 'Calculation_123456789', fieldCaption: 'Profit Ratio', + columnClass: 'CALCULATION', dataType: 'REAL', defaultAggregation: 'SUM', logicalTableId: '', + formula: 'SUM([Profit])/SUM([Sales])', }, { fieldName: 'Product Name', fieldCaption: 'Product Name', dataType: 'STRING', + defaultAggregation: 'COUNT', logicalTableId: 'Orders_123456789', + columnClass: 'COLUMN', }, { fieldName: 'Quantity', @@ -27,8 +31,49 @@ const mockReadMetadataResponses = vi.hoisted(() => ({ dataType: 'INTEGER', defaultAggregation: 'SUM', logicalTableId: 'Orders_123456789', + columnClass: 'COLUMN', }, ], + extraData: { + parameters: [ + { + parameterType: 'QUANTITATIVE_DATE', + parameterName: 'Parameter 1', + parameterCaption: 'Test Date', + dataType: 'DATE', + value: '2025-10-17', + minDate: '2024-01-01', + maxDate: '2026-01-01', + periodType: null, + periodValue: null, + }, + { + parameterType: 'QUANTITATIVE_RANGE', + parameterName: 'Parameter 2', + parameterCaption: 'Test Float', + dataType: 'REAL', + value: 2.5, + min: 1.5, + max: null, + step: 1, + }, + { + parameterType: 'LIST', + parameterName: 'Parameter 3', + parameterCaption: 'Test Int', + dataType: 'INTEGER', + value: 1, + members: [1, 2, 3], + }, + { + parameterType: 'ANY_VALUE', + parameterName: 'Parameter 4', + parameterCaption: 'Test String', + dataType: 'STRING', + value: 'Hello World!', + }, + ], + }, }, empty: { data: [], @@ -229,6 +274,40 @@ describe('getDatasourceMetadataTool', () => { defaultFormat: '#,##0', }, ], + parameters: [ + { + dataType: 'DATE', + maxDate: '2026-01-01', + minDate: '2024-01-01', + name: 'Test Date', + parameterType: 'QUANTITATIVE_DATE', + periodType: null, + periodValue: null, + value: '2025-10-17', + }, + { + dataType: 'REAL', + min: 1.5, + max: null, + step: 1, + name: 'Test Float', + parameterType: 'QUANTITATIVE_RANGE', + value: 2.5, + }, + { + dataType: 'INTEGER', + members: [1, 2, 3], + name: 'Test Int', + parameterType: 'LIST', + value: 1, + }, + { + dataType: 'STRING', + name: 'Test String', + parameterType: 'ANY_VALUE', + value: 'Hello World!', + }, + ], }); expect(mocks.mockReadMetadata).toHaveBeenCalledWith({ @@ -249,6 +328,7 @@ describe('getDatasourceMetadataTool', () => { const responseData = JSON.parse(result.content[0].text as string); expect(responseData).toEqual({ fields: [], + parameters: [], }); }); @@ -306,6 +386,7 @@ describe('getDatasourceMetadataTool', () => { role: 'DIMENSION', }, ], + parameters: [], }); }); @@ -325,6 +406,8 @@ describe('getDatasourceMetadataTool', () => { name: 'Profit Ratio', dataType: 'REAL', defaultAggregation: 'SUM', + columnClass: 'CALCULATION', + formula: 'SUM([Profit])/SUM([Sales])', }, { name: 'Product Name', @@ -336,6 +419,40 @@ describe('getDatasourceMetadataTool', () => { defaultAggregation: 'SUM', }, ], + parameters: [ + { + dataType: 'DATE', + maxDate: '2026-01-01', + minDate: '2024-01-01', + name: 'Test Date', + parameterType: 'QUANTITATIVE_DATE', + periodType: null, + periodValue: null, + value: '2025-10-17', + }, + { + dataType: 'REAL', + min: 1.5, + max: null, + step: 1, + name: 'Test Float', + parameterType: 'QUANTITATIVE_RANGE', + value: 2.5, + }, + { + dataType: 'INTEGER', + members: [1, 2, 3], + name: 'Test Int', + parameterType: 'LIST', + value: 1, + }, + { + dataType: 'STRING', + name: 'Test String', + parameterType: 'ANY_VALUE', + value: 'Hello World!', + }, + ], }); // Ensure no enriched fields are present