Skip to main content
Glama
getDatasourceMetadata.test.ts22.7 kB
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { Err, Ok } from 'ts-results-es'; import { Server } from '../../server.js'; import { getVizqlDataServiceDisabledError } from '../getVizqlDataServiceDisabledError.js'; import { exportedForTesting as resourceAccessCheckerExportedForTesting } from '../resourceAccessChecker.js'; import { getGetDatasourceMetadataTool } from './getDatasourceMetadata.js'; const { resetResourceAccessCheckerSingleton } = resourceAccessCheckerExportedForTesting; const mockReadMetadataResponses = vi.hoisted(() => ({ success: { data: [ { 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', fieldCaption: 'Quantity', 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: [], }, nullData: { data: null, }, })); const mockListFieldsResponses = vi.hoisted(() => ({ success: { data: { publishedDatasources: [ { name: 'Test Datasource', description: 'Test Description', owner: { name: 'Test Owner', }, fields: [ { name: 'Profit Ratio', isHidden: false, description: 'Calculated profit ratio field', descriptionInherited: [ { attribute: 'description', value: 'Inherited profit description', }, ], fullyQualifiedName: '[Profit Ratio]', __typename: 'CalculatedField', dataCategory: 'QUANTITATIVE', role: 'MEASURE', dataType: 'REAL', defaultFormat: 'p2', semanticRole: null, aggregation: 'Sum', aggregationParam: null, formula: 'SUM([Sales] - [Cost])', isAutoGenerated: false, hasUserReference: true, }, { name: 'Product Name', isHidden: false, description: 'Name of the product', descriptionInherited: [], fullyQualifiedName: '[Product Name]', __typename: 'ColumnField', dataCategory: 'NOMINAL', role: 'DIMENSION', dataType: 'STRING', defaultFormat: null, semanticRole: null, aggregation: null, aggregationParam: null, }, { name: 'Quantity', isHidden: false, description: 'Quantity ordered', descriptionInherited: [], fullyQualifiedName: '[Quantity]', __typename: 'ColumnField', dataCategory: 'QUANTITATIVE', role: 'MEASURE', dataType: 'INTEGER', defaultFormat: '#,##0', semanticRole: null, aggregation: 'Sum', aggregationParam: null, }, { name: 'Binned Field', isHidden: false, description: 'A binned field', descriptionInherited: [], fullyQualifiedName: '[Binned Field]', __typename: 'BinField', dataCategory: 'ORDINAL', role: 'DIMENSION', dataType: 'INTEGER', formula: 'BIN([Some Field])', binSize: 10, }, ], }, ], }, }, empty: { data: { publishedDatasources: [], }, }, emptyFields: { data: { publishedDatasources: [ { name: 'Test Datasource', fields: [], }, ], }, }, })); const mocks = vi.hoisted(() => ({ mockReadMetadata: vi.fn(), mockGraphql: vi.fn(), mockGetConfig: vi.fn(), })); vi.mock('../../restApiInstance.js', () => ({ useRestApi: vi.fn().mockImplementation(async ({ callback }) => callback({ vizqlDataServiceMethods: { readMetadata: mocks.mockReadMetadata, }, metadataMethods: { graphql: mocks.mockGraphql, }, }), ), })); vi.mock('../../config.js', () => ({ getConfig: mocks.mockGetConfig, })); describe('getDatasourceMetadataTool', () => { beforeEach(() => { vi.clearAllMocks(); // Set default config for existing tests resetResourceAccessCheckerSingleton(); mocks.mockGetConfig.mockReturnValue({ disableMetadataApiRequests: false, boundedContext: { projectIds: null, datasourceIds: null, workbookIds: null, }, }); }); it('should create a tool instance with correct properties', () => { const getDatasourceMetadataTool = getGetDatasourceMetadataTool(new Server()); expect(getDatasourceMetadataTool.name).toBe('get-datasource-metadata'); expect(getDatasourceMetadataTool.description).toEqual(expect.any(String)); expect(getDatasourceMetadataTool.paramsSchema).toMatchObject({ datasourceLuid: expect.any(Object), }); expect(getDatasourceMetadataTool.annotations).toMatchObject({ title: 'Get Datasource Metadata', readOnlyHint: true, openWorldHint: false, }); }); it('should successfully merge data from both APIs and return enriched metadata', async () => { mocks.mockReadMetadata.mockResolvedValue(new Ok(mockReadMetadataResponses.success)); mocks.mockGraphql.mockResolvedValue(mockListFieldsResponses.success); const result = await getToolResult(); expect(result.isError).toBe(false); const responseData = JSON.parse(result.content[0].text as string); expect(responseData).toMatchObject({ fields: [ { name: 'Profit Ratio', dataType: 'REAL', defaultAggregation: 'SUM', description: 'Calculated profit ratio field', descriptionInherited: [ { attribute: 'description', value: 'Inherited profit description', }, ], dataCategory: 'QUANTITATIVE', role: 'MEASURE', defaultFormat: 'p2', formula: 'SUM([Sales] - [Cost])', isAutoGenerated: false, hasUserReference: true, }, { name: 'Product Name', dataType: 'STRING', description: 'Name of the product', dataCategory: 'NOMINAL', role: 'DIMENSION', }, { name: 'Quantity', dataType: 'INTEGER', defaultAggregation: 'SUM', description: 'Quantity ordered', dataCategory: 'QUANTITATIVE', role: 'MEASURE', 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({ datasource: { datasourceLuid: 'test-luid', }, }); expect(mocks.mockGraphql).toHaveBeenCalledWith(expect.stringContaining('datasourceFieldInfo')); }); it('should handle empty readMetadata response gracefully', async () => { mocks.mockReadMetadata.mockResolvedValue(new Ok(mockReadMetadataResponses.empty)); mocks.mockGraphql.mockResolvedValue(mockListFieldsResponses.success); const result = await getToolResult(); expect(result.isError).toBe(false); const responseData = JSON.parse(result.content[0].text as string); expect(responseData).toEqual({ fields: [], parameters: [], }); }); it('should handle null readMetadata data gracefully', async () => { mocks.mockReadMetadata.mockResolvedValue(new Ok(mockReadMetadataResponses.nullData)); mocks.mockGraphql.mockResolvedValue(mockListFieldsResponses.success); const result = await getToolResult(); expect(result.isError).toBe(false); const responseData = JSON.parse(result.content[0].text as string); expect(responseData).toEqual({ fields: [ { dataCategory: 'QUANTITATIVE', dataType: 'REAL', defaultAggregation: 'Sum', defaultFormat: 'p2', description: 'Calculated profit ratio field', descriptionInherited: [ { attribute: 'description', value: 'Inherited profit description', }, ], formula: 'SUM([Sales] - [Cost])', hasUserReference: true, isAutoGenerated: false, name: 'Profit Ratio', role: 'MEASURE', }, { dataCategory: 'NOMINAL', dataType: 'STRING', description: 'Name of the product', name: 'Product Name', role: 'DIMENSION', }, { dataCategory: 'QUANTITATIVE', dataType: 'INTEGER', defaultAggregation: 'Sum', defaultFormat: '#,##0', description: 'Quantity ordered', name: 'Quantity', role: 'MEASURE', }, { binSize: 10, dataCategory: 'ORDINAL', dataType: 'INTEGER', description: 'A binned field', formula: 'BIN([Some Field])', name: 'Binned Field', role: 'DIMENSION', }, ], parameters: [], }); }); it('should handle empty listFields response and return basic metadata only', async () => { mocks.mockReadMetadata.mockResolvedValue(new Ok(mockReadMetadataResponses.success)); mocks.mockGraphql.mockResolvedValue(mockListFieldsResponses.empty); const result = await getToolResult(); expect(result.isError).toBe(false); const responseData = JSON.parse(result.content[0].text as string); // Should have basic fields from readMetadata without enrichment expect(responseData).toMatchObject({ fields: [ { name: 'Profit Ratio', dataType: 'REAL', defaultAggregation: 'SUM', columnClass: 'CALCULATION', formula: 'SUM([Profit])/SUM([Sales])', }, { name: 'Product Name', dataType: 'STRING', }, { name: 'Quantity', dataType: 'INTEGER', 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 expect(responseData.fields[0]).not.toHaveProperty('description'); expect(responseData.fields[0]).not.toHaveProperty('dataCategory'); }); it('should handle empty fields in listFields response', async () => { mocks.mockReadMetadata.mockResolvedValue(new Ok(mockReadMetadataResponses.success)); mocks.mockGraphql.mockResolvedValue(mockListFieldsResponses.emptyFields); const result = await getToolResult(); expect(result.isError).toBe(false); const responseData = JSON.parse(result.content[0].text as string); // Should have basic fields from readMetadata without enrichment expect(responseData.fields).toHaveLength(3); expect(responseData.fields[0]).not.toHaveProperty('description'); }); it('should handle partial field matching between APIs', async () => { // readMetadata has fields that aren't in listFields const partialReadMetadata = { data: [ { fieldName: 'Existing Field', fieldCaption: 'Existing Field', dataType: 'STRING', logicalTableId: '', }, { fieldName: 'Missing Field', fieldCaption: 'Missing Field', dataType: 'INTEGER', logicalTableId: '', }, ], }; const partialListFields = { data: { publishedDatasources: [ { fields: [ { name: 'Existing Field', description: 'This field exists in both', dataCategory: 'NOMINAL', role: 'DIMENSION', }, ], }, ], }, }; mocks.mockReadMetadata.mockResolvedValue(new Ok(partialReadMetadata)); mocks.mockGraphql.mockResolvedValue(partialListFields); const result = await getToolResult(); expect(result.isError).toBe(false); const responseData = JSON.parse(result.content[0].text as string); expect(responseData.fields).toHaveLength(2); // First field should be enriched expect(responseData.fields[0]).toMatchObject({ name: 'Existing Field', dataType: 'STRING', description: 'This field exists in both', dataCategory: 'NOMINAL', role: 'DIMENSION', }); // Second field should only have basic data expect(responseData.fields[1]).toMatchObject({ name: 'Missing Field', dataType: 'INTEGER', }); expect(responseData.fields[1]).not.toHaveProperty('description'); }); it('should handle binSize property for BinField types', async () => { const readMetadataWithBin = { data: [ { fieldName: 'Binned Field', fieldCaption: 'Binned Field', dataType: 'INTEGER', logicalTableId: '', }, ], }; mocks.mockReadMetadata.mockResolvedValue(new Ok(readMetadataWithBin)); mocks.mockGraphql.mockResolvedValue(mockListFieldsResponses.success); const result = await getToolResult(); expect(result.isError).toBe(false); const responseData = JSON.parse(result.content[0].text as string); expect(responseData.fields[0]).toMatchObject({ name: 'Binned Field', dataType: 'INTEGER', binSize: 10, }); }); it('should handle readMetadata API errors gracefully', async () => { const errorMessage = 'ReadMetadata API Error'; mocks.mockReadMetadata.mockRejectedValue(new Error(errorMessage)); mocks.mockGraphql.mockResolvedValue(mockListFieldsResponses.success); const result = await getToolResult(); expect(result.isError).toBe(true); expect(result.content[0].text).toBe( 'requestId: test-request-id, error: ReadMetadata API Error', ); }); it('should handle listFields API errors gracefully', async () => { const errorMessage = 'GraphQL API Error'; mocks.mockReadMetadata.mockResolvedValue(new Ok(mockReadMetadataResponses.success)); mocks.mockGraphql.mockRejectedValue(new Error(errorMessage)); const result = await getToolResult(); expect(result.isError).toBe(false); const responseData = JSON.parse(result.content[0].text as string); expect(responseData).toMatchObject({ fields: [ { name: 'Profit Ratio', dataType: 'REAL', defaultAggregation: 'SUM', }, { name: 'Product Name', dataType: 'STRING', }, { name: 'Quantity', dataType: 'INTEGER', defaultAggregation: 'SUM', }, ], }); }); it('should handle when both APIs fail', async () => { const readMetadataError = 'ReadMetadata API Error'; const graphqlError = 'GraphQL API Error'; mocks.mockReadMetadata.mockRejectedValue(new Error(readMetadataError)); mocks.mockGraphql.mockRejectedValue(new Error(graphqlError)); const result = await getToolResult(); expect(result.isError).toBe(true); // Should fail with the first error (readMetadata is called first) expect(result.content[0].text).toBe( 'requestId: test-request-id, error: ReadMetadata API Error', ); }); it('should return only readMetadata result when disableMetadataApiRequests is true and readMetadata succeeds', async () => { // Configure to disable metadata API requests mocks.mockGetConfig.mockReturnValue({ disableMetadataApiRequests: true, boundedContext: { projectIds: null, datasourceIds: null, workbookIds: null, }, }); mocks.mockReadMetadata.mockResolvedValue(new Ok(mockReadMetadataResponses.success)); mocks.mockGraphql.mockResolvedValue(mockListFieldsResponses.success); const result = await getToolResult(); expect(result.isError).toBe(false); const responseData = JSON.parse(result.content[0].text as string); // Should only have basic fields from readMetadata without enrichment expect(responseData).toMatchObject({ fields: [ { name: 'Profit Ratio', dataType: 'REAL', defaultAggregation: 'SUM', }, { name: 'Product Name', dataType: 'STRING', }, { name: 'Quantity', dataType: 'INTEGER', defaultAggregation: 'SUM', }, ], }); // Ensure no enriched fields are present expect(responseData.fields[0]).not.toHaveProperty('description'); expect(responseData.fields[0]).not.toHaveProperty('dataCategory'); expect(responseData.fields[0]).not.toHaveProperty('role'); // Verify readMetadata was called but graphql was not expect(mocks.mockReadMetadata).toHaveBeenCalledWith({ datasource: { datasourceLuid: 'test-luid', }, }); expect(mocks.mockGraphql).not.toHaveBeenCalled(); }); it('should return error when disableMetadataApiRequests is true and readMetadata fails', async () => { // Configure to disable metadata API requests mocks.mockGetConfig.mockReturnValue({ disableMetadataApiRequests: true, boundedContext: { projectIds: null, datasourceIds: null, workbookIds: null, }, }); const errorMessage = 'ReadMetadata API Error'; mocks.mockReadMetadata.mockRejectedValue(new Error(errorMessage)); mocks.mockGraphql.mockResolvedValue(mockListFieldsResponses.success); const result = await getToolResult(); expect(result.isError).toBe(true); expect(result.content[0].text).toBe( 'requestId: test-request-id, error: ReadMetadata API Error', ); // Verify readMetadata was called but graphql was not expect(mocks.mockReadMetadata).toHaveBeenCalledWith({ datasource: { datasourceLuid: 'test-luid', }, }); expect(mocks.mockGraphql).not.toHaveBeenCalled(); }); it('should show feature-disabled error when VDS is disabled', async () => { mocks.mockReadMetadata.mockResolvedValue(Err('feature-disabled')); const result = await getToolResult(); expect(result.isError).toBe(true); expect(result.content[0].text).toBe(getVizqlDataServiceDisabledError()); expect(mocks.mockGraphql).not.toHaveBeenCalled(); }); it('should return data source not allowed error when datasource is not allowed', async () => { mocks.mockGetConfig.mockReturnValue({ boundedContext: { projectIds: null, datasourceIds: new Set(['some-other-datasource-luid']), workbookIds: null, }, }); const result = await getToolResult(); expect(result.isError).toBe(true); expect(result.content[0].text).toBe( [ 'The set of allowed data sources that can be queried is limited by the server configuration.', 'Querying the datasource with LUID test-luid is not allowed.', ].join(' '), ); expect(mocks.mockReadMetadata).not.toHaveBeenCalled(); expect(mocks.mockGraphql).not.toHaveBeenCalled(); }); }); async function getToolResult(): Promise<CallToolResult> { const getDatasourceMetadataTool = getGetDatasourceMetadataTool(new Server()); return await getDatasourceMetadataTool.callback( { datasourceLuid: 'test-luid' }, { signal: new AbortController().signal, requestId: 'test-request-id', sendNotification: vi.fn(), sendRequest: vi.fn(), }, ); }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/datalabs89/tableau-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server