Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ describe('Config', () => {
MAX_RESULT_LIMIT: undefined,
DISABLE_QUERY_DATASOURCE_FILTER_VALIDATION: undefined,
DISABLE_METADATA_API_REQUESTS: undefined,
ENABLE_SERVER_LOGGING: undefined,
SERVER_LOG_DIRECTORY: undefined,
INCLUDE_PROJECT_IDS: undefined,
INCLUDE_DATASOURCE_IDS: undefined,
INCLUDE_WORKBOOK_IDS: undefined,
};
});

Expand Down Expand Up @@ -647,4 +652,73 @@ describe('Config', () => {
expect(config.jwtAdditionalPayload).toBe('{}');
});
});

describe('Bounded context parsing', () => {
it('should set boundedContext to null sets when no project, datasource, or workbook IDs are provided', () => {
process.env = {
...process.env,
...defaultEnvVars,
};

const config = new Config();
expect(config.boundedContext).toEqual({
projectIds: null,
datasourceIds: null,
workbookIds: null,
});
});

it('should set boundedContext to the specified project, datasource, and workbook IDs when provided', () => {
process.env = {
...process.env,
...defaultEnvVars,
INCLUDE_PROJECT_IDS: ' 123, 456, 123 ', // spacing is intentional here to test trimming
INCLUDE_DATASOURCE_IDS: '789,101',
INCLUDE_WORKBOOK_IDS: '112,113',
};

const config = new Config();
expect(config.boundedContext).toEqual({
projectIds: new Set(['123', '456']),
datasourceIds: new Set(['789', '101']),
workbookIds: new Set(['112', '113']),
});
});

it('should throw error when INCLUDE_PROJECT_IDS is set to an empty string', () => {
process.env = {
...process.env,
...defaultEnvVars,
INCLUDE_PROJECT_IDS: '',
};

expect(() => new Config()).toThrow(
'When set, the environment variable INCLUDE_PROJECT_IDS must have at least one value',
);
});

it('should throw error when INCLUDE_DATASOURCE_IDS is set to an empty string', () => {
process.env = {
...process.env,
...defaultEnvVars,
INCLUDE_DATASOURCE_IDS: '',
};

expect(() => new Config()).toThrow(
'When set, the environment variable INCLUDE_DATASOURCE_IDS must have at least one value',
);
});

it('should throw error when INCLUDE_WORKBOOK_IDS is set to an empty string', () => {
process.env = {
...process.env,
...defaultEnvVars,
INCLUDE_WORKBOOK_IDS: '',
};

expect(() => new Config()).toThrow(
'When set, the environment variable INCLUDE_WORKBOOK_IDS must have at least one value',
);
});
});
});
49 changes: 49 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url));
const authTypes = ['pat', 'direct-trust'] as const;
type AuthType = (typeof authTypes)[number];

export type BoundedContext = {
projectIds: Set<string> | null;
datasourceIds: Set<string> | null;
workbookIds: Set<string> | null;
};

export class Config {
auth: AuthType;
server: string;
Expand All @@ -37,6 +43,7 @@ export class Config {
disableMetadataApiRequests: boolean;
enableServerLogging: boolean;
serverLogDirectory: string;
boundedContext: BoundedContext;

constructor() {
const cleansedVars = removeClaudeMcpBundleUserConfigTemplates(process.env);
Expand Down Expand Up @@ -66,6 +73,9 @@ export class Config {
DISABLE_METADATA_API_REQUESTS: disableMetadataApiRequests,
ENABLE_SERVER_LOGGING: enableServerLogging,
SERVER_LOG_DIRECTORY: serverLogDirectory,
INCLUDE_PROJECT_IDS: includeProjectIds,
INCLUDE_DATASOURCE_IDS: includeDatasourceIds,
INCLUDE_WORKBOOK_IDS: includeWorkbookIds,
} = cleansedVars;

const defaultPort = 3927;
Expand All @@ -86,6 +96,29 @@ export class Config {
this.disableMetadataApiRequests = disableMetadataApiRequests === 'true';
this.enableServerLogging = enableServerLogging === 'true';
this.serverLogDirectory = serverLogDirectory || join(__dirname, 'logs');
this.boundedContext = {
projectIds: createSetFromCommaSeparatedString(includeProjectIds),
datasourceIds: createSetFromCommaSeparatedString(includeDatasourceIds),
workbookIds: createSetFromCommaSeparatedString(includeWorkbookIds),
};

if (this.boundedContext.projectIds?.size === 0) {
throw new Error(
'When set, the environment variable INCLUDE_PROJECT_IDS must have at least one value',
);
}

if (this.boundedContext.datasourceIds?.size === 0) {
throw new Error(
'When set, the environment variable INCLUDE_DATASOURCE_IDS must have at least one value',
);
}

if (this.boundedContext.workbookIds?.size === 0) {
throw new Error(
'When set, the environment variable INCLUDE_WORKBOOK_IDS must have at least one value',
);
}

const maxResultLimitNumber = maxResultLimit ? parseInt(maxResultLimit) : NaN;
this.maxResultLimit =
Expand Down Expand Up @@ -181,6 +214,22 @@ function getCorsOriginConfig(corsOriginConfig: string): CorsOptions['origin'] {
}
}

// Creates a set from a comma-separated string of values.
// Returns null if the value is undefined.
function createSetFromCommaSeparatedString(value: string | undefined): Set<string> | null {
if (value === undefined) {
return null;
}

return new Set(
value
.trim()
.split(',')
.map((id) => id.trim())
.filter(Boolean),
);
}

// When the user does not provide a site name in the Claude MCP Bundle configuration,
// Claude doesn't replace its value and sets the site name to "${user_config.site_name}".
function removeClaudeMcpBundleUserConfigTemplates(
Expand Down
24 changes: 24 additions & 0 deletions src/scripts/createClaudeMcpBundleManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,30 @@ const envVars = {
required: false,
sensitive: false,
},
INCLUDE_PROJECT_IDS: {
includeInUserConfig: false,
type: 'string',
title: 'IDs of projects to constrain tool results by',
description: 'A comma-separated list of project IDs to constrain tool results by.',
required: false,
sensitive: false,
},
INCLUDE_DATASOURCE_IDS: {
includeInUserConfig: false,
type: 'string',
title: 'IDs of datasources to constrain tool results by',
description: 'A comma-separated list of datasource IDs to constrain tool results by.',
required: false,
sensitive: false,
},
INCLUDE_WORKBOOK_IDS: {
includeInUserConfig: false,
type: 'string',
title: 'IDs of workbooks to constrain tool results by',
description: 'A comma-separated list of workbook IDs to constrain tool results by.',
required: false,
sensitive: false,
},
MAX_RESULT_LIMIT: {
includeInUserConfig: false,
type: 'number',
Expand Down
14 changes: 12 additions & 2 deletions src/sdks/tableau/apis/datasourcesApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { dataSourceSchema } from '../types/dataSource.js';
import { paginationSchema } from '../types/pagination.js';
import { paginationParameters } from './paginationParameters.js';

const listDatasourcesRestEndpoint = makeEndpoint({
const listDatasourcesEndpoint = makeEndpoint({
method: 'get',
path: '/sites/:siteId/datasources',
alias: 'listDatasources',
Expand Down Expand Up @@ -33,5 +33,15 @@ const listDatasourcesRestEndpoint = makeEndpoint({
}),
});

const datasourcesApi = makeApi([listDatasourcesRestEndpoint]);
const queryDatasourceEndpoint = makeEndpoint({
method: 'get',
path: '/sites/:siteId/datasources/:datasourceId',
alias: 'queryDatasource',
description: 'Returns information about the specified data source.',
response: z.object({
datasource: dataSourceSchema,
}),
});

const datasourcesApi = makeApi([listDatasourcesEndpoint, queryDatasourceEndpoint]);
export const datasourcesApis = [...datasourcesApi] as const satisfies ZodiosEndpointDefinitions;
9 changes: 9 additions & 0 deletions src/sdks/tableau/apis/viewsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ import { paginationSchema } from '../types/pagination.js';
import { viewSchema } from '../types/view.js';
import { paginationParameters } from './paginationParameters.js';

const getViewEndpoint = makeEndpoint({
method: 'get',
path: `/sites/:siteId/views/:viewId`,
alias: 'getView',
description: 'Gets the details of a specific view.',
response: z.object({ view: viewSchema }),
});

const queryViewDataEndpoint = makeEndpoint({
method: 'get',
path: `/sites/:siteId/views/:viewId/data`,
Expand Down Expand Up @@ -90,6 +98,7 @@ const queryViewsForSiteEndpoint = makeEndpoint({
});

const viewsApi = makeApi([
getViewEndpoint,
queryViewDataEndpoint,
queryViewImageEndpoint,
queryViewsForWorkbookEndpoint,
Expand Down
24 changes: 24 additions & 0 deletions src/sdks/tableau/methods/datasourcesMethods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,28 @@ export default class DatasourcesMethods extends AuthenticatedMethods<typeof data
datasources: response.datasources.datasource ?? [],
};
};

/**
* Returns information about the specified data source.
*
* Required scopes: `tableau:content:read`
*
* @param siteId - The Tableau site ID
* @param datasourceId - The ID of the data source
* @link https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#query_data_source
*/
queryDatasource = async ({
siteId,
datasourceId,
}: {
siteId: string;
datasourceId: string;
}): Promise<DataSource> => {
return (
await this._apiClient.queryDatasource({
params: { siteId, datasourceId },
...this.authHeader,
})
).datasource;
};
}
7 changes: 4 additions & 3 deletions src/sdks/tableau/methods/pulseMethods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { pulseApis } from '../apis/pulseApi.js';
import { Credentials } from '../types/credentials.js';
import {
pulseBundleRequestSchema,
pulseBundleResponseSchema,
PulseBundleResponse,
PulseInsightBundleType,
PulseMetric,
PulseMetricDefinition,
Expand Down Expand Up @@ -139,7 +139,7 @@ export default class PulseMethods extends AuthenticatedMethods<typeof pulseApis>
generatePulseMetricValueInsightBundle = async (
bundleRequest: z.infer<typeof pulseBundleRequestSchema>,
bundleType: PulseInsightBundleType,
): Promise<PulseResult<z.infer<typeof pulseBundleResponseSchema>>> => {
): Promise<PulseResult<PulseBundleResponse>> => {
return await guardAgainstPulseDisabled(async () => {
const response = await this._apiClient.generatePulseMetricValueInsightBundle(
{ bundle_request: bundleRequest.bundle_request },
Expand All @@ -150,7 +150,8 @@ export default class PulseMethods extends AuthenticatedMethods<typeof pulseApis>
};
}

type PulseResult<T> = Result<T, 'tableau-server' | 'pulse-disabled'>;
export type PulseDisabledError = 'tableau-server' | 'pulse-disabled';
type PulseResult<T> = Result<T, PulseDisabledError>;
async function guardAgainstPulseDisabled<T>(callback: () => Promise<T>): Promise<PulseResult<T>> {
try {
return new Ok(await callback());
Expand Down
13 changes: 13 additions & 0 deletions src/sdks/tableau/methods/viewsMethods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ export default class ViewsMethods extends AuthenticatedMethods<typeof viewsApis>
super(new Zodios(baseUrl, viewsApis), creds);
}

/**
* Gets the details of a specific view.
*
* Required scopes: `tableau:content:read`
*
* @param {string} viewId The ID of the view to get.
* @param {string} siteId - The Tableau site ID
* @link https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_view
*/
getView = async ({ viewId, siteId }: { viewId: string; siteId: string }): Promise<View> => {
return (await this._apiClient.getView({ params: { siteId, viewId }, ...this.authHeader })).view;
};

/**
* Returns a specified view rendered as data in comma separated value (CSV) format.
*
Expand Down
1 change: 1 addition & 0 deletions src/sdks/tableau/types/pulse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ function createValidPulseMetric(overrides = {}): any {
comparison: { comparison: 'previous_period' },
},
definition_id: 'BBC908D8-29ED-48AB-A78E-ACF8A424C8C3',
datasource_luid: 'A6FC3C9F-4F40-4906-8DB0-AC70C5FB5A11',
is_default: true,
schema_version: '1.0',
metric_version: '1',
Expand Down
3 changes: 3 additions & 0 deletions src/sdks/tableau/types/pulse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const pulseMetricSchema = z.object({
metric_version: z.coerce.number(),
goals: pulseGoalsSchema.optional(),
is_followed: z.boolean(),
datasource_luid: z.string(),
});

export const pulseRepresentationOptionsSchema = z.object({
Expand Down Expand Up @@ -339,6 +340,8 @@ export const pulseBundleResponseSchema = z.object({
}),
});

export type PulseBundleResponse = z.infer<typeof pulseBundleResponseSchema>;

export const pulseInsightBundleTypeEnum = ['ban', 'springboard', 'basic', 'detail'] as const;
export type PulseInsightBundleType = (typeof pulseInsightBundleTypeEnum)[number];

Expand Down
6 changes: 4 additions & 2 deletions src/tools/contentExploration/searchContent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,8 +308,10 @@ describe('searchContentTool', () => {
const result = await getToolResult({ terms: 'nonexistent' });

expect(result.isError).toBe(false);
const responseData = JSON.parse(result.content[0].text as string);
expect(responseData).toEqual([]);
const responseData = result.content[0].text as string;
expect(responseData).toEqual(
'No search results were found. Either none exist or you do not have permission to view them',
);
});
});

Expand Down
Loading