From 2bdd795da7a6af24b851197e156c0e2533e74418 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Fri, 6 Feb 2026 16:52:10 -0800 Subject: [PATCH 01/18] Add getConfigWithOverrides --- src/config.ts | 45 +++++- src/restApiInstance.ts | 83 +++++----- src/scripts/createClaudeMcpBundleManifest.ts | 8 + src/sdks/tableau/getSiteIdFromAccessToken.ts | 9 ++ src/sdks/tableau/restApi.ts | 21 ++- src/sdks/tableau/types/mcpSiteSettings.ts | 4 + src/server.ts | 28 +++- src/server/express.ts | 15 +- src/tools/contentExploration/searchContent.ts | 19 ++- src/tools/listDatasources/listDatasources.ts | 19 ++- .../generatePulseInsightBriefTool.ts | 12 +- ...neratePulseMetricValueInsightBundleTool.ts | 13 +- .../listAllPulseMetricDefinitions.ts | 19 ++- ...PulseMetricDefinitionsFromDefinitionIds.ts | 19 ++- .../listPulseMetricSubscriptions.ts | 19 ++- .../listPulseMetricsFromMetricDefinitionId.ts | 19 ++- .../listPulseMetricsFromMetricIds.ts | 19 ++- src/tools/resourceAccessChecker.ts | 147 ++++++++++-------- src/utils/mcpSiteSettings.ts | 57 +++++++ src/utils/restApiArgs.ts | 14 ++ types/process-env.d.ts | 1 + 21 files changed, 451 insertions(+), 139 deletions(-) create mode 100644 src/sdks/tableau/getSiteIdFromAccessToken.ts create mode 100644 src/sdks/tableau/types/mcpSiteSettings.ts create mode 100644 src/utils/mcpSiteSettings.ts create mode 100644 src/utils/restApiArgs.ts diff --git a/src/config.ts b/src/config.ts index 482258e0..1dc3fdc1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,7 @@ import { CorsOptions } from 'cors'; import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; +import { ProcessEnvEx } from '../types/process-env.js'; import { isTelemetryProvider, providerConfigSchema, TelemetryConfig } from './telemetry/types.js'; import { isToolGroupName, isToolName, toolGroups, ToolName } from './tools/toolName.js'; import { isTransport, TransportName } from './transports.js'; @@ -20,7 +21,35 @@ const authTypes = ['pat', 'uat', 'direct-trust', 'oauth'] as const; type AuthType = (typeof authTypes)[number]; function isAuthType(auth: unknown): auth is AuthType { - return !!authTypes.find((type) => type === auth); + return authTypes.some((type) => type === auth); +} + +export const overrideableEnvironmentVariables = [ + 'INCLUDE_TOOLS', + 'EXCLUDE_TOOLS', + 'INCLUDE_PROJECT_IDS', + 'INCLUDE_DATASOURCE_IDS', + 'INCLUDE_WORKBOOK_IDS', + 'INCLUDE_TAGS', + 'MAX_RESULT_LIMIT', + 'MAX_RESULT_LIMITS', + 'DISABLE_QUERY_DATASOURCE_VALIDATION_REQUESTS', + 'DISABLE_METADATA_API_REQUESTS', +] as const satisfies ReadonlyArray; + +type OverrideableEnvironmentVariable = (typeof overrideableEnvironmentVariables)[number]; +function isOverrideableEnvironmentVariable( + variable: unknown, +): variable is OverrideableEnvironmentVariable { + return overrideableEnvironmentVariables.some((v) => v === variable); +} + +function filterEnvVarsToOverrideable( + environmentVariables: Record, +): Record { + return Object.fromEntries( + Object.entries(environmentVariables).filter(([key]) => isOverrideableEnvironmentVariable(key)), + ) as Record; } export type BoundedContext = { @@ -68,6 +97,7 @@ export class Config { serverLogDirectory: string; boundedContext: BoundedContext; tableauServerVersionCheckIntervalInHours: number; + enableMcpSiteSettings: boolean; oauth: { enabled: boolean; issuer: string; @@ -88,8 +118,12 @@ export class Config { return this.maxResultLimits?.get(toolName) ?? this.maxResultLimit; } - constructor() { + constructor(overrides?: Record) { const cleansedVars = removeClaudeMcpBundleUserConfigTemplates(process.env); + const filteredVars = overrides + ? { ...cleansedVars, ...filterEnvVarsToOverrideable(overrides) } + : cleansedVars; + const { AUTH: auth, SERVER: server, @@ -132,6 +166,7 @@ export class Config { INCLUDE_WORKBOOK_IDS: includeWorkbookIds, INCLUDE_TAGS: includeTags, TABLEAU_SERVER_VERSION_CHECK_INTERVAL_IN_HOURS: tableauServerVersionCheckIntervalInHours, + ENABLE_MCP_SITE_SETTINGS: enableMcpSiteSettings, DANGEROUSLY_DISABLE_OAUTH: disableOauth, OAUTH_ISSUER: oauthIssuer, OAUTH_LOCK_SITE: oauthLockSite, @@ -146,7 +181,7 @@ export class Config { OAUTH_REFRESH_TOKEN_TIMEOUT_MS: refreshTokenTimeoutMs, TELEMETRY_PROVIDER: telemetryProvider, TELEMETRY_PROVIDER_CONFIG: telemetryProviderConfig, - } = cleansedVars; + } = filteredVars; let jwtUsername = ''; @@ -210,6 +245,7 @@ export class Config { }, ); + this.enableMcpSiteSettings = enableMcpSiteSettings === 'true'; const disableOauthOverride = disableOauth === 'true'; this.oauth = { enabled: disableOauthOverride ? false : !!oauthIssuer, @@ -554,7 +590,8 @@ function parseNumber( : number; } -export const getConfig = (): Config => new Config(); +export const getConfig = (overrides?: Record): Config => + new Config(overrides); export const exportedForTesting = { Config, diff --git a/src/restApiInstance.ts b/src/restApiInstance.ts index 50ba844d..86e44b01 100644 --- a/src/restApiInstance.ts +++ b/src/restApiInstance.ts @@ -28,31 +28,43 @@ type JwtScopes = | 'tableau:metric_subscriptions:read' | 'tableau:insights:read' | 'tableau:views:download' - | 'tableau:insight_brief:create'; + | 'tableau:insight_brief:create' + | 'tableau:mcp_site_settings:read'; -const getNewRestApiInstanceAsync = async ( - config: Config, - requestId: RequestId, - server: Server, - jwtScopes: Set, - signal: AbortSignal, - authInfo?: TableauAuthInfo, -): Promise => { - signal.addEventListener( - 'abort', - () => { - log.info( - server, - { - type: 'request-cancelled', - requestId, - reason: signal.reason, - }, - { logger: server.name, requestId }, - ); - }, - { once: true }, - ); +const getNewRestApiInstanceAsync = async ({ + config, + requestId, + server, + jwtScopes, + signal, + authInfo, + disableLogging, +}: { + config: Config; + requestId: RequestId; + server: Server; + jwtScopes: Set; + signal: AbortSignal; + authInfo?: TableauAuthInfo; + disableLogging: boolean; +}): Promise => { + if (!disableLogging) { + signal.addEventListener( + 'abort', + () => { + log.info( + server, + { + type: 'request-cancelled', + requestId, + reason: signal.reason, + }, + { logger: server.name, requestId }, + ); + }, + { once: true }, + ); + } const tableauServer = config.server || authInfo?.server; invariant(tableauServer, 'Tableau server could not be determined'); @@ -60,14 +72,12 @@ const getNewRestApiInstanceAsync = async ( const restApi = new RestApi(tableauServer, { maxRequestTimeoutMs: config.maxRequestTimeoutMs, signal, - requestInterceptor: [ - getRequestInterceptor(server, requestId), - getRequestErrorInterceptor(server, requestId), - ], - responseInterceptor: [ - getResponseInterceptor(server, requestId), - getResponseErrorInterceptor(server, requestId), - ], + requestInterceptor: disableLogging + ? undefined + : [getRequestInterceptor(server, requestId), getRequestErrorInterceptor(server, requestId)], + responseInterceptor: disableLogging + ? undefined + : [getResponseInterceptor(server, requestId), getResponseErrorInterceptor(server, requestId)], }); if (config.auth === 'pat') { @@ -120,6 +130,7 @@ export const useRestApi = async ({ jwtScopes, signal, authInfo, + disableLogging = false, }: { config: Config; requestId: RequestId; @@ -128,15 +139,17 @@ export const useRestApi = async ({ signal: AbortSignal; callback: (restApi: RestApi) => Promise; authInfo?: TableauAuthInfo; + disableLogging?: boolean; }): Promise => { - const restApi = await getNewRestApiInstanceAsync( + const restApi = await getNewRestApiInstanceAsync({ config, requestId, server, - new Set(jwtScopes), + jwtScopes: new Set(jwtScopes), signal, authInfo, - ); + disableLogging, + }); try { return await callback(restApi); } finally { diff --git a/src/scripts/createClaudeMcpBundleManifest.ts b/src/scripts/createClaudeMcpBundleManifest.ts index 80089862..e904e3c5 100644 --- a/src/scripts/createClaudeMcpBundleManifest.ts +++ b/src/scripts/createClaudeMcpBundleManifest.ts @@ -365,6 +365,14 @@ const envVars = { required: false, sensitive: false, }, + ENABLE_MCP_SITE_SETTINGS: { + includeInUserConfig: false, + type: 'boolean', + title: 'Enable MCP Site Settings', + description: 'Enable MCP site settings.', + required: false, + sensitive: false, + }, DANGEROUSLY_DISABLE_OAUTH: { includeInUserConfig: false, type: 'boolean', diff --git a/src/sdks/tableau/getSiteIdFromAccessToken.ts b/src/sdks/tableau/getSiteIdFromAccessToken.ts new file mode 100644 index 00000000..d6c3a0cf --- /dev/null +++ b/src/sdks/tableau/getSiteIdFromAccessToken.ts @@ -0,0 +1,9 @@ +export function getSiteIdFromAccessToken(accessToken: string): string { + const parts = accessToken.split('|'); + if (parts.length < 3) { + throw new Error('Could not determine site ID. Access token must have 3 parts.'); + } + + const siteId = parts[2]; + return siteId; +} diff --git a/src/sdks/tableau/restApi.ts b/src/sdks/tableau/restApi.ts index 152baceb..83e7e1b5 100644 --- a/src/sdks/tableau/restApi.ts +++ b/src/sdks/tableau/restApi.ts @@ -1,4 +1,5 @@ import { AuthConfig } from './authConfig.js'; +import { getSiteIdFromAccessToken } from './getSiteIdFromAccessToken.js'; import { AxiosInterceptor, ErrorInterceptor, @@ -20,6 +21,7 @@ import ViewsMethods from './methods/viewsMethods.js'; import VizqlDataServiceMethods from './methods/vizqlDataServiceMethods.js'; import WorkbooksMethods from './methods/workbooksMethods.js'; import { Credentials } from './types/credentials.js'; +import { McpSiteSettings } from './types/mcpSiteSettings.js'; /** * Interface for the Tableau REST APIs @@ -185,6 +187,18 @@ export class RestApi { return this._serverMethods; } + // Temporary until we have proper site methods + get siteMethods(): { getMcpSettings: () => Promise } { + return { + getMcpSettings: async (): Promise => { + return { + INCLUDE_DATASOURCE_IDS: '2d935df8-fe7e-4fd8-bb14-35eb4ba31d4', + EXCLUDE_TOOLS: 'pulse', + }; + }, + }; + } + get vizqlDataServiceMethods(): VizqlDataServiceMethods { if (!this._vizqlDataServiceMethods) { const baseUrl = `${this._host}/api/v1/vizql-data-service`; @@ -232,12 +246,7 @@ export class RestApi { }; setCredentials = (accessToken: string, userId: string): void => { - const parts = accessToken.split('|'); - if (parts.length < 3) { - throw new Error('Could not determine site ID. Access token must have 3 parts.'); - } - - const siteId = parts[2]; + const siteId = getSiteIdFromAccessToken(accessToken); this._creds = { site: { id: siteId, diff --git a/src/sdks/tableau/types/mcpSiteSettings.ts b/src/sdks/tableau/types/mcpSiteSettings.ts new file mode 100644 index 00000000..f0db5179 --- /dev/null +++ b/src/sdks/tableau/types/mcpSiteSettings.ts @@ -0,0 +1,4 @@ +import { z } from 'zod'; + +export const mcpSiteSettingsSchema = z.record(z.string(), z.string()); +export type McpSiteSettings = z.infer; diff --git a/src/server.ts b/src/server.ts index e92528a4..bda1be3d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,13 +1,17 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { InitializeRequest, SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { + InitializeRequest, + RequestId, + SetLevelRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; import pkg from '../package.json'; -import { getConfig } from './config.js'; import { setLogLevel } from './logging/log.js'; import { TableauAuthInfo } from './server/oauth/schemas.js'; import { Tool } from './tools/tool.js'; import { toolNames } from './tools/toolName.js'; import { toolFactories } from './tools/tools.js'; +import { getConfigWithOverrides } from './utils/mcpSiteSettings'; import { Provider } from './utils/provider.js'; export const serverName = 'tableau-mcp'; @@ -53,14 +57,14 @@ export class Server extends McpServer { this._clientInfo = clientInfo; } - registerTools = async (authInfo?: TableauAuthInfo): Promise => { + registerTools = async (requestId?: RequestId, authInfo?: TableauAuthInfo): Promise => { for (const { name, description, paramsSchema, annotations, callback, - } of this._getToolsToRegister(authInfo)) { + } of await this._getToolsToRegister(requestId ?? 'no-request-id', authInfo)) { this.registerTool( name, { @@ -80,8 +84,20 @@ export class Server extends McpServer { }); }; - private _getToolsToRegister = (authInfo?: TableauAuthInfo): Array> => { - const { includeTools, excludeTools } = getConfig(); + private _getToolsToRegister = async ( + requestId: RequestId, + authInfo?: TableauAuthInfo, + ): Promise>> => { + const config = await getConfigWithOverrides({ + restApiArgs: { + requestId, + server: this, + authInfo, + disableLogging: true, // MCP server is not connected yet so we can't send logging notifications + }, + }); + + const { includeTools, excludeTools } = config; const tools = toolFactories.map((toolFactory) => toolFactory(this, authInfo)); const toolsToRegister = tools.filter((tool) => { diff --git a/src/server/express.ts b/src/server/express.ts index 7b52548c..ecf1b17c 100644 --- a/src/server/express.ts +++ b/src/server/express.ts @@ -1,5 +1,10 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { isInitializeRequest, LoggingLevel } from '@modelcontextprotocol/sdk/types.js'; +import { + isInitializeRequest, + isJSONRPCRequest, + LoggingLevel, + RequestId, +} from '@modelcontextprotocol/sdk/types.js'; import cors from 'cors'; import express, { Request, RequestHandler, Response } from 'express'; import fs, { existsSync } from 'fs'; @@ -108,6 +113,7 @@ export async function startExpressServer({ async function createMcpServer(req: AuthenticatedRequest, res: Response): Promise { try { let transport: StreamableHTTPServerTransport; + const requestId = (isJSONRPCRequest(req.body) && req.body.id) || 'no-request-id'; if (config.disableSessionManagement) { const server = new Server(); @@ -120,7 +126,7 @@ export async function startExpressServer({ server.close(); }); - await connect(server, transport, logLevel, getTableauAuthInfo(req.auth)); + await connect(server, transport, logLevel, requestId, getTableauAuthInfo(req.auth)); } else { const sessionId = req.headers[SESSION_ID_HEADER] as string | undefined; @@ -132,7 +138,7 @@ export async function startExpressServer({ transport = createSession({ clientInfo }); const server = new Server({ clientInfo }); - await connect(server, transport, logLevel, getTableauAuthInfo(req.auth)); + await connect(server, transport, logLevel, requestId, getTableauAuthInfo(req.auth)); } else { // Invalid request res.status(400).json({ @@ -168,9 +174,10 @@ async function connect( server: Server, transport: StreamableHTTPServerTransport, logLevel: LoggingLevel, + requestId: RequestId, authInfo: TableauAuthInfo | undefined, ): Promise { - await server.registerTools(authInfo); + await server.registerTools(requestId, authInfo); server.registerRequestHandlers(); await server.connect(transport); diff --git a/src/tools/contentExploration/searchContent.ts b/src/tools/contentExploration/searchContent.ts index 61a7e3be..5587781f 100644 --- a/src/tools/contentExploration/searchContent.ts +++ b/src/tools/contentExploration/searchContent.ts @@ -10,6 +10,7 @@ import { } from '../../sdks/tableau/types/contentExploration.js'; import { Server } from '../../server.js'; import { getTableauAuthInfo } from '../../server/oauth/getTableauAuthInfo.js'; +import { getConfigWithOverrides } from '../../utils/mcpSiteSettings.js'; import { Tool } from '../tool.js'; import { buildFilterString, @@ -95,8 +96,22 @@ This tool searches across all supported content types for objects relevant to th }), ); }, - constrainSuccessResult: (items) => - constrainSearchContent({ items, boundedContext: config.boundedContext }), + constrainSuccessResult: async (items) => { + const configWithOverrides = await getConfigWithOverrides({ + restApiArgs: { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }, + }); + + return constrainSearchContent({ + items, + boundedContext: configWithOverrides.boundedContext, + }); + }, }); }, }); diff --git a/src/tools/listDatasources/listDatasources.ts b/src/tools/listDatasources/listDatasources.ts index 16242611..607541e0 100644 --- a/src/tools/listDatasources/listDatasources.ts +++ b/src/tools/listDatasources/listDatasources.ts @@ -7,6 +7,7 @@ import { useRestApi } from '../../restApiInstance.js'; import { DataSource } from '../../sdks/tableau/types/dataSource.js'; import { Server } from '../../server.js'; import { getTableauAuthInfo } from '../../server/oauth/getTableauAuthInfo.js'; +import { getConfigWithOverrides } from '../../utils/mcpSiteSettings.js'; import { paginate } from '../../utils/paginate.js'; import { genericFilterDescription } from '../genericFilterDescription.js'; import { ConstrainedResult, Tool } from '../tool.js'; @@ -124,8 +125,22 @@ export const getListDatasourcesTool = (server: Server): Tool - constrainDatasources({ datasources, boundedContext: config.boundedContext }), + constrainSuccessResult: async (datasources) => { + const configWithOverrides = await getConfigWithOverrides({ + restApiArgs: { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }, + }); + + return constrainDatasources({ + datasources, + boundedContext: configWithOverrides.boundedContext, + }); + }, }); }, }); diff --git a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts index b30df778..f7178e68 100644 --- a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts +++ b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts @@ -10,6 +10,7 @@ import { } from '../../../sdks/tableau/types/pulse.js'; import { Server } from '../../../server.js'; import { getTableauAuthInfo } from '../../../server/oauth/getTableauAuthInfo.js'; +import { getConfigWithOverrides } from '../../../utils/mcpSiteSettings.js'; import { Tool } from '../../tool.js'; import { getPulseDisabledError } from '../getPulseDisabledError.js'; @@ -196,6 +197,15 @@ An insight brief is an AI-generated response to questions about Pulse metrics. I { briefRequest }, { requestId, authInfo, signal }, ): Promise => { + const configWithOverrides = await getConfigWithOverrides({ + restApiArgs: { + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }, + }); + const config = getConfig(); return await generatePulseInsightBriefTool.logAndExecute< PulseInsightBriefResponse, @@ -206,7 +216,7 @@ An insight brief is an AI-generated response to questions about Pulse metrics. I args: { briefRequest }, callback: async () => { // Filter out metrics that are not in the allowed datasource set - const { datasourceIds } = config.boundedContext; + const { datasourceIds } = configWithOverrides.boundedContext; if (datasourceIds) { for (const message of briefRequest.messages) { if (message.metric_group_context) { diff --git a/src/tools/pulse/generateMetricValueInsightBundle/generatePulseMetricValueInsightBundleTool.ts b/src/tools/pulse/generateMetricValueInsightBundle/generatePulseMetricValueInsightBundleTool.ts index c95516c6..8bb6d3b1 100644 --- a/src/tools/pulse/generateMetricValueInsightBundle/generatePulseMetricValueInsightBundleTool.ts +++ b/src/tools/pulse/generateMetricValueInsightBundle/generatePulseMetricValueInsightBundleTool.ts @@ -12,6 +12,7 @@ import { } from '../../../sdks/tableau/types/pulse.js'; import { Server } from '../../../server.js'; import { getTableauAuthInfo } from '../../../server/oauth/getTableauAuthInfo.js'; +import { getConfigWithOverrides } from '../../../utils/mcpSiteSettings.js'; import { Tool } from '../../tool.js'; import { getPulseDisabledError } from '../getPulseDisabledError.js'; @@ -163,7 +164,17 @@ Generate an insight bundle for the current aggregated value for Pulse Metric usi authInfo, args: { bundleRequest, bundleType }, callback: async () => { - const { datasourceIds } = config.boundedContext; + const configWithOverrides = await getConfigWithOverrides({ + restApiArgs: { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }, + }); + + const { datasourceIds } = configWithOverrides.boundedContext; if (datasourceIds) { const datasourceLuid = bundleRequest.bundle_request.input.metric.definition.datasource.id; diff --git a/src/tools/pulse/listAllMetricDefinitions/listAllPulseMetricDefinitions.ts b/src/tools/pulse/listAllMetricDefinitions/listAllPulseMetricDefinitions.ts index 973dcd18..71690b58 100644 --- a/src/tools/pulse/listAllMetricDefinitions/listAllPulseMetricDefinitions.ts +++ b/src/tools/pulse/listAllMetricDefinitions/listAllPulseMetricDefinitions.ts @@ -10,6 +10,7 @@ import { } from '../../../sdks/tableau/types/pulse.js'; import { Server } from '../../../server.js'; import { getTableauAuthInfo } from '../../../server/oauth/getTableauAuthInfo.js'; +import { getConfigWithOverrides } from '../../../utils/mcpSiteSettings.js'; import { pulsePaginate } from '../../../utils/paginate.js'; import { Tool } from '../../tool.js'; import { constrainPulseDefinitions } from '../constrainPulseDefinitions.js'; @@ -106,8 +107,22 @@ Retrieves a list of all published Pulse Metric Definitions using the Tableau RES }, }); }, - constrainSuccessResult: (definitions: Array) => - constrainPulseDefinitions({ definitions, boundedContext: config.boundedContext }), + constrainSuccessResult: async (definitions: Array) => { + const configWithOverrides = await getConfigWithOverrides({ + restApiArgs: { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }, + }); + + return constrainPulseDefinitions({ + definitions, + boundedContext: configWithOverrides.boundedContext, + }); + }, getErrorText: getPulseDisabledError, }); }, diff --git a/src/tools/pulse/listMetricDefinitionsFromDefinitionIds/listPulseMetricDefinitionsFromDefinitionIds.ts b/src/tools/pulse/listMetricDefinitionsFromDefinitionIds/listPulseMetricDefinitionsFromDefinitionIds.ts index 91e98d82..756c4761 100644 --- a/src/tools/pulse/listMetricDefinitionsFromDefinitionIds/listPulseMetricDefinitionsFromDefinitionIds.ts +++ b/src/tools/pulse/listMetricDefinitionsFromDefinitionIds/listPulseMetricDefinitionsFromDefinitionIds.ts @@ -6,6 +6,7 @@ import { useRestApi } from '../../../restApiInstance.js'; import { pulseMetricDefinitionViewEnum } from '../../../sdks/tableau/types/pulse.js'; import { Server } from '../../../server.js'; import { getTableauAuthInfo } from '../../../server/oauth/getTableauAuthInfo.js'; +import { getConfigWithOverrides } from '../../../utils/mcpSiteSettings.js'; import { Tool } from '../../tool.js'; import { constrainPulseDefinitions } from '../constrainPulseDefinitions.js'; import { getPulseDisabledError } from '../getPulseDisabledError.js'; @@ -82,8 +83,22 @@ Retrieves a list of specific Pulse Metric Definitions using the Tableau REST API }, }); }, - constrainSuccessResult: (definitions) => - constrainPulseDefinitions({ definitions, boundedContext: config.boundedContext }), + constrainSuccessResult: async (definitions) => { + const configWithOverrides = await getConfigWithOverrides({ + restApiArgs: { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }, + }); + + return constrainPulseDefinitions({ + definitions, + boundedContext: configWithOverrides.boundedContext, + }); + }, getErrorText: getPulseDisabledError, }); }, diff --git a/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts b/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts index 75dd3e86..b6d04718 100644 --- a/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts +++ b/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts @@ -6,7 +6,8 @@ import { PulseMetricSubscription } from '../../../sdks/tableau/types/pulse.js'; import { Server } from '../../../server.js'; import { getTableauAuthInfo } from '../../../server/oauth/getTableauAuthInfo.js'; import { getExceptionMessage } from '../../../utils/getExceptionMessage.js'; -import { RestApiArgs } from '../../resourceAccessChecker.js'; +import { getConfigWithOverrides } from '../../../utils/mcpSiteSettings.js'; +import { RestApiArgs } from '../../../utils/restApiArgs.js'; import { ConstrainedResult, Tool } from '../../tool.js'; import { getPulseDisabledError } from '../getPulseDisabledError.js'; @@ -54,10 +55,22 @@ Retrieves a list of published Pulse Metric Subscriptions for the current user us }); }, constrainSuccessResult: async (subscriptions) => { + const restApiArgs = { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }; + + const configWithOverrides = await getConfigWithOverrides({ + restApiArgs, + }); + return await constrainPulseMetricSubscriptions({ subscriptions, - boundedContext: config.boundedContext, - restApiArgs: { config, requestId, server, signal }, + boundedContext: configWithOverrides.boundedContext, + restApiArgs, }); }, getErrorText: getPulseDisabledError, diff --git a/src/tools/pulse/listMetricsFromMetricDefinitionId/listPulseMetricsFromMetricDefinitionId.ts b/src/tools/pulse/listMetricsFromMetricDefinitionId/listPulseMetricsFromMetricDefinitionId.ts index 143ae3c3..96e1b8ae 100644 --- a/src/tools/pulse/listMetricsFromMetricDefinitionId/listPulseMetricsFromMetricDefinitionId.ts +++ b/src/tools/pulse/listMetricsFromMetricDefinitionId/listPulseMetricsFromMetricDefinitionId.ts @@ -7,6 +7,7 @@ import { PulseDisabledError } from '../../../sdks/tableau/methods/pulseMethods.j import { PulseMetric } from '../../../sdks/tableau/types/pulse.js'; import { Server } from '../../../server.js'; import { getTableauAuthInfo } from '../../../server/oauth/getTableauAuthInfo.js'; +import { getConfigWithOverrides } from '../../../utils/mcpSiteSettings.js'; import { Tool } from '../../tool.js'; import { constrainPulseMetrics } from '../constrainPulseMetrics.js'; import { getPulseDisabledError } from '../getPulseDisabledError.js'; @@ -63,8 +64,22 @@ Retrieves a list of published Pulse Metrics from a Pulse Metric Definition using }, }); }, - constrainSuccessResult: (metrics) => - constrainPulseMetrics({ metrics, boundedContext: config.boundedContext }), + constrainSuccessResult: async (metrics) => { + const configWithOverrides = await getConfigWithOverrides({ + restApiArgs: { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }, + }); + + return constrainPulseMetrics({ + metrics, + boundedContext: configWithOverrides.boundedContext, + }); + }, getErrorText: getPulseDisabledError, }); }, diff --git a/src/tools/pulse/listMetricsFromMetricIds/listPulseMetricsFromMetricIds.ts b/src/tools/pulse/listMetricsFromMetricIds/listPulseMetricsFromMetricIds.ts index ad5db886..35e74190 100644 --- a/src/tools/pulse/listMetricsFromMetricIds/listPulseMetricsFromMetricIds.ts +++ b/src/tools/pulse/listMetricsFromMetricIds/listPulseMetricsFromMetricIds.ts @@ -5,6 +5,7 @@ import { getConfig } from '../../../config.js'; import { useRestApi } from '../../../restApiInstance.js'; import { Server } from '../../../server.js'; import { getTableauAuthInfo } from '../../../server/oauth/getTableauAuthInfo.js'; +import { getConfigWithOverrides } from '../../../utils/mcpSiteSettings.js'; import { Tool } from '../../tool.js'; import { constrainPulseMetrics } from '../constrainPulseMetrics.js'; import { getPulseDisabledError } from '../getPulseDisabledError.js'; @@ -57,8 +58,22 @@ Retrieves a list of published Pulse Metrics from a list of metric IDs using the }, }); }, - constrainSuccessResult: (metrics) => - constrainPulseMetrics({ metrics, boundedContext: config.boundedContext }), + constrainSuccessResult: async (metrics) => { + const configWithOverrides = await getConfigWithOverrides({ + restApiArgs: { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }, + }); + + return constrainPulseMetrics({ + metrics, + boundedContext: configWithOverrides.boundedContext, + }); + }, getErrorText: getPulseDisabledError, }); }, diff --git a/src/tools/resourceAccessChecker.ts b/src/tools/resourceAccessChecker.ts index b0bb9e79..d4cd8900 100644 --- a/src/tools/resourceAccessChecker.ts +++ b/src/tools/resourceAccessChecker.ts @@ -1,24 +1,16 @@ -import { RequestId } from '@modelcontextprotocol/sdk/types.js'; - -import { BoundedContext, Config, getConfig } from '../config.js'; +import { BoundedContext } from '../config.js'; import { useRestApi } from '../restApiInstance.js'; import { DataSource } from '../sdks/tableau/types/dataSource.js'; import { View } from '../sdks/tableau/types/view.js'; import { Workbook } from '../sdks/tableau/types/workbook.js'; -import { Server } from '../server.js'; import { getExceptionMessage } from '../utils/getExceptionMessage.js'; +import { getConfigWithOverrides } from '../utils/mcpSiteSettings.js'; +import { RestApiArgs } from '../utils/restApiArgs.js'; type AllowedResult = | { allowed: true; content?: T } | { allowed: false; message: string }; -export type RestApiArgs = { - config: Config; - requestId: RequestId; - server: Server; - signal: AbortSignal; -}; - class ResourceAccessChecker { private _allowedProjectIds: Set | null | undefined; private _allowedDatasourceIds: Set | null | undefined; @@ -50,36 +42,51 @@ class ResourceAccessChecker { this._cachedViewIds = new Map(); } - private get allowedProjectIds(): Set | null { - if (this._allowedProjectIds === undefined) { - this._allowedProjectIds = getConfig().boundedContext.projectIds; - } + private async setBoundedContext({ restApiArgs }: { restApiArgs: RestApiArgs }): Promise { + const { boundedContext } = await getConfigWithOverrides({ + restApiArgs, + }); - return this._allowedProjectIds; + this._allowedProjectIds = boundedContext.projectIds; + this._allowedDatasourceIds = boundedContext.datasourceIds; + this._allowedWorkbookIds = boundedContext.workbookIds; + this._allowedTags = boundedContext.tags; } - private get allowedDatasourceIds(): Set | null { - if (this._allowedDatasourceIds === undefined) { - this._allowedDatasourceIds = getConfig().boundedContext.datasourceIds; - } - - return this._allowedDatasourceIds; + private async getAllowedProjectIds({ + restApiArgs, + }: { + restApiArgs: RestApiArgs; + }): Promise | null> { + await this.setBoundedContext({ restApiArgs }); + return this._allowedProjectIds!; } - private get allowedWorkbookIds(): Set | null { - if (this._allowedWorkbookIds === undefined) { - this._allowedWorkbookIds = getConfig().boundedContext.workbookIds; - } - - return this._allowedWorkbookIds; + private async getAllowedDatasourceIds({ + restApiArgs, + }: { + restApiArgs: RestApiArgs; + }): Promise | null> { + await this.setBoundedContext({ restApiArgs }); + return this._allowedDatasourceIds!; } - private get allowedTags(): Set | null { - if (this._allowedTags === undefined) { - this._allowedTags = getConfig().boundedContext.tags; - } + private async getAllowedWorkbookIds({ + restApiArgs, + }: { + restApiArgs: RestApiArgs; + }): Promise | null> { + await this.setBoundedContext({ restApiArgs }); + return this._allowedWorkbookIds!; + } - return this._allowedTags; + private async getAllowedTags({ + restApiArgs, + }: { + restApiArgs: RestApiArgs; + }): Promise | null> { + await this.setBoundedContext({ restApiArgs }); + return this._allowedTags!; } async isDatasourceAllowed({ @@ -94,7 +101,9 @@ class ResourceAccessChecker { restApiArgs, }); - if (!this.allowedProjectIds && !this.allowedTags) { + const allowedProjectIds = await this.getAllowedProjectIds({ restApiArgs }); + const allowedTags = await this.getAllowedTags({ restApiArgs }); + if (!allowedProjectIds && !allowedTags) { // If project filtering is enabled, we cannot cache the result since the datasource may be moved between projects. // If tag filtering is enabled, we cannot cache the result since the datasource tags can change over time. this._cachedDatasourceIds.set(datasourceLuid, result); @@ -115,7 +124,9 @@ class ResourceAccessChecker { restApiArgs, }); - if (!this.allowedProjectIds && !this.allowedTags) { + const allowedProjectIds = await this.getAllowedProjectIds({ restApiArgs }); + const allowedTags = await this.getAllowedTags({ restApiArgs }); + if (!allowedProjectIds && !allowedTags) { // If project filtering is enabled, we cannot cache the result since the workbook may be moved between projects. // If tag filtering is enabled, we cannot cache the result since the workbook tags can change over time. this._cachedWorkbookIds.set(workbookId, result); @@ -136,7 +147,9 @@ class ResourceAccessChecker { restApiArgs, }); - if (!this.allowedProjectIds && !this.allowedTags) { + const allowedProjectIds = await this.getAllowedProjectIds({ restApiArgs }); + const allowedTags = await this.getAllowedTags({ restApiArgs }); + if (!allowedProjectIds && !allowedTags) { // If project filtering is enabled, we cannot cache the result since the workbook containing the view may be moved between projects. // If tag filtering is enabled, we cannot cache the result since the view tags can change over time. this._cachedViewIds.set(viewId, result); @@ -147,7 +160,7 @@ class ResourceAccessChecker { private async _isDatasourceAllowed({ datasourceLuid, - restApiArgs: { config, requestId, server, signal }, + restApiArgs, }: { datasourceLuid: string; restApiArgs: RestApiArgs; @@ -157,7 +170,8 @@ class ResourceAccessChecker { return cachedResult; } - if (this.allowedDatasourceIds && !this.allowedDatasourceIds.has(datasourceLuid)) { + const allowedDatasourceIds = await this.getAllowedDatasourceIds({ restApiArgs }); + if (allowedDatasourceIds && !allowedDatasourceIds.has(datasourceLuid)) { return { allowed: false, message: [ @@ -170,11 +184,8 @@ class ResourceAccessChecker { let datasource: DataSource | undefined; async function getDatasource(): Promise { return await useRestApi({ - config, - requestId, - server, + ...restApiArgs, jwtScopes: ['tableau:content:read'], - signal, callback: async (restApi) => await restApi.datasourcesMethods.queryDatasource({ siteId: restApi.siteId, @@ -183,11 +194,12 @@ class ResourceAccessChecker { }); } - if (this.allowedProjectIds) { + const allowedProjectIds = await this.getAllowedProjectIds({ restApiArgs }); + if (allowedProjectIds) { try { datasource = await getDatasource(); - if (!this.allowedProjectIds.has(datasource.project.id)) { + if (!allowedProjectIds?.has(datasource.project.id)) { return { allowed: false, message: [ @@ -208,11 +220,12 @@ class ResourceAccessChecker { } } - if (this.allowedTags) { + const allowedTags = await this.getAllowedTags({ restApiArgs }); + if (allowedTags) { try { datasource = datasource ?? (await getDatasource()); - if (!datasource.tags?.tag?.some((tag) => this.allowedTags?.has(tag.label))) { + if (!datasource.tags?.tag?.some((tag) => allowedTags?.has(tag.label))) { return { allowed: false, message: [ @@ -238,7 +251,7 @@ class ResourceAccessChecker { private async _isWorkbookAllowed({ workbookId, - restApiArgs: { config, requestId, server, signal }, + restApiArgs, }: { workbookId: string; restApiArgs: RestApiArgs; @@ -248,7 +261,8 @@ class ResourceAccessChecker { return cachedResult; } - if (this.allowedWorkbookIds && !this.allowedWorkbookIds.has(workbookId)) { + const allowedWorkbookIds = await this.getAllowedWorkbookIds({ restApiArgs }); + if (allowedWorkbookIds && !allowedWorkbookIds.has(workbookId)) { return { allowed: false, message: [ @@ -261,11 +275,8 @@ class ResourceAccessChecker { let workbook: Workbook | undefined; async function getWorkbook(): Promise { return await useRestApi({ - config, - requestId, - server, + ...restApiArgs, jwtScopes: ['tableau:content:read'], - signal, callback: async (restApi) => await restApi.workbooksMethods.getWorkbook({ siteId: restApi.siteId, @@ -274,11 +285,12 @@ class ResourceAccessChecker { }); } - if (this.allowedProjectIds) { + const allowedProjectIds = await this.getAllowedProjectIds({ restApiArgs }); + if (allowedProjectIds) { try { workbook = await getWorkbook(); - if (!this.allowedProjectIds.has(workbook.project?.id ?? '')) { + if (!allowedProjectIds.has(workbook.project?.id ?? '')) { return { allowed: false, message: [ @@ -299,11 +311,12 @@ class ResourceAccessChecker { } } - if (this.allowedTags) { + const allowedTags = await this.getAllowedTags({ restApiArgs }); + if (allowedTags) { try { workbook = workbook ?? (await getWorkbook()); - if (!workbook.tags?.tag?.some((tag) => this.allowedTags?.has(tag.label))) { + if (!workbook.tags?.tag?.some((tag) => allowedTags?.has(tag.label))) { return { allowed: false, message: [ @@ -329,7 +342,7 @@ class ResourceAccessChecker { private async _isViewAllowed({ viewId, - restApiArgs: { config, requestId, server, signal }, + restApiArgs, }: { viewId: string; restApiArgs: RestApiArgs; @@ -342,11 +355,8 @@ class ResourceAccessChecker { let view: View | undefined; async function getView(): Promise { return await useRestApi({ - config, - requestId, - server, + ...restApiArgs, jwtScopes: ['tableau:content:read'], - signal, callback: async (restApi) => { return await restApi.viewsMethods.getView({ siteId: restApi.siteId, @@ -356,11 +366,12 @@ class ResourceAccessChecker { }); } - if (this.allowedWorkbookIds) { + const allowedWorkbookIds = await this.getAllowedWorkbookIds({ restApiArgs }); + if (allowedWorkbookIds) { try { view = await getView(); - if (!this.allowedWorkbookIds.has(view.workbook?.id ?? '')) { + if (!allowedWorkbookIds.has(view.workbook?.id ?? '')) { return { allowed: false, message: [ @@ -381,11 +392,12 @@ class ResourceAccessChecker { } } - if (this.allowedProjectIds) { + const allowedProjectIds = await this.getAllowedProjectIds({ restApiArgs }); + if (allowedProjectIds) { try { view = view ?? (await getView()); - if (!this.allowedProjectIds.has(view.project?.id ?? '')) { + if (!allowedProjectIds.has(view.project?.id ?? '')) { return { allowed: false, message: [ @@ -406,11 +418,12 @@ class ResourceAccessChecker { } } - if (this.allowedTags) { + const allowedTags = await this.getAllowedTags({ restApiArgs }); + if (allowedTags) { try { view = view ?? (await getView()); - if (!view.tags?.tag?.some((tag) => this.allowedTags?.has(tag.label))) { + if (!view.tags?.tag?.some((tag) => allowedTags?.has(tag.label))) { return { allowed: false, message: [ diff --git a/src/utils/mcpSiteSettings.ts b/src/utils/mcpSiteSettings.ts new file mode 100644 index 00000000..090af386 --- /dev/null +++ b/src/utils/mcpSiteSettings.ts @@ -0,0 +1,57 @@ +import { Config, getConfig, TEN_MINUTES_IN_MS } from '../config.js'; +import { useRestApi } from '../restApiInstance.js'; +import { getSiteIdFromAccessToken } from '../sdks/tableau/getSiteIdFromAccessToken.js'; +import { McpSiteSettings } from '../sdks/tableau/types/mcpSiteSettings.js'; +import { ExpiringMap } from './expiringMap.js'; +import { RestApiArgs } from './restApiArgs.js'; + +type SiteNameOrSiteId = string; +const mcpSiteSettingsCache = new ExpiringMap({ + defaultExpirationTimeMs: TEN_MINUTES_IN_MS, +}); + +async function getMcpSiteSettings({ + restApiArgs: { config, requestId, server, signal, disableLogging, authInfo }, +}: { + restApiArgs: RestApiArgs; +}): Promise { + if (!config.enableMcpSiteSettings) { + return; + } + + const cacheKey = config.siteName || getSiteIdFromAccessToken(authInfo?.accessToken ?? ''); + const cachedSettings = mcpSiteSettingsCache.get(cacheKey); + if (cachedSettings) { + return cachedSettings; + } + + const settings = await useRestApi({ + config, + requestId, + server, + jwtScopes: ['tableau:mcp_site_settings:read'], + signal, + authInfo, + disableLogging, + callback: async (restApi) => await restApi.siteMethods.getMcpSettings(), + }); + + mcpSiteSettingsCache.set(cacheKey, settings); + return settings; +} + +export async function getConfigWithOverrides({ + restApiArgs, +}: { + restApiArgs: Omit & + Partial<{ config: Config; signal: AbortSignal }>; +}): Promise { + const config = restApiArgs.config ?? getConfig(); + const signal = restApiArgs.signal ?? AbortSignal.timeout(config.maxRequestTimeoutMs); + + const overrides = await getMcpSiteSettings({ + restApiArgs: { ...restApiArgs, config, signal }, + }); + + return overrides ? getConfig(overrides) : config; +} diff --git a/src/utils/restApiArgs.ts b/src/utils/restApiArgs.ts new file mode 100644 index 00000000..cc73bae0 --- /dev/null +++ b/src/utils/restApiArgs.ts @@ -0,0 +1,14 @@ +import { RequestId } from '@modelcontextprotocol/sdk/types.js'; + +import { Config } from '../config.js'; +import { Server } from '../server.js'; +import { TableauAuthInfo } from '../server/oauth/schemas.js'; + +export type RestApiArgs = { + config: Config; + requestId: RequestId; + server: Server; + signal: AbortSignal; + disableLogging?: boolean; + authInfo?: TableauAuthInfo; +}; diff --git a/types/process-env.d.ts b/types/process-env.d.ts index 586d8a8f..8ed18a36 100644 --- a/types/process-env.d.ts +++ b/types/process-env.d.ts @@ -40,6 +40,7 @@ export interface ProcessEnvEx { INCLUDE_WORKBOOK_IDS: string | undefined; INCLUDE_TAGS: string | undefined; TABLEAU_SERVER_VERSION_CHECK_INTERVAL_IN_HOURS: string | undefined; + ENABLE_MCP_SITE_SETTINGS: string | undefined; DANGEROUSLY_DISABLE_OAUTH: string | undefined; OAUTH_ISSUER: string | undefined; OAUTH_JWE_PRIVATE_KEY: string | undefined; From 0592d13e0ef7ccaf5f304f1ce12e983422f1f0bf Mon Sep 17 00:00:00 2001 From: Andy Young Date: Sat, 7 Feb 2026 10:09:26 -0800 Subject: [PATCH 02/18] Mark fields as test overrides --- src/tools/resourceAccessChecker.ts | 73 ++++++++++++++++++------------ 1 file changed, 45 insertions(+), 28 deletions(-) diff --git a/src/tools/resourceAccessChecker.ts b/src/tools/resourceAccessChecker.ts index d4cd8900..6de00078 100644 --- a/src/tools/resourceAccessChecker.ts +++ b/src/tools/resourceAccessChecker.ts @@ -12,10 +12,12 @@ type AllowedResult = | { allowed: false; message: string }; class ResourceAccessChecker { - private _allowedProjectIds: Set | null | undefined; - private _allowedDatasourceIds: Set | null | undefined; - private _allowedWorkbookIds: Set | null | undefined; - private _allowedTags: Set | null | undefined; + private _testOverrides: { + projectIds: Set | null | undefined; + datasourceIds: Set | null | undefined; + workbookIds: Set | null | undefined; + tags: Set | null | undefined; + }; private readonly _cachedDatasourceIds: Map; private readonly _cachedWorkbookIds: Map>; @@ -30,36 +32,33 @@ class ResourceAccessChecker { } // Optional bounded context to use for testing. - private constructor(boundedContext?: BoundedContext) { + private constructor(testOverrides?: BoundedContext) { // The methods assume these sets are non-empty. - this._allowedProjectIds = boundedContext?.projectIds; - this._allowedDatasourceIds = boundedContext?.datasourceIds; - this._allowedWorkbookIds = boundedContext?.workbookIds; - this._allowedTags = boundedContext?.tags; + this._testOverrides = { + projectIds: testOverrides?.projectIds, + datasourceIds: testOverrides?.datasourceIds, + workbookIds: testOverrides?.workbookIds, + tags: testOverrides?.tags, + }; this._cachedDatasourceIds = new Map(); this._cachedWorkbookIds = new Map(); this._cachedViewIds = new Map(); } - private async setBoundedContext({ restApiArgs }: { restApiArgs: RestApiArgs }): Promise { - const { boundedContext } = await getConfigWithOverrides({ - restApiArgs, - }); - - this._allowedProjectIds = boundedContext.projectIds; - this._allowedDatasourceIds = boundedContext.datasourceIds; - this._allowedWorkbookIds = boundedContext.workbookIds; - this._allowedTags = boundedContext.tags; - } - private async getAllowedProjectIds({ restApiArgs, }: { restApiArgs: RestApiArgs; }): Promise | null> { - await this.setBoundedContext({ restApiArgs }); - return this._allowedProjectIds!; + return ( + this._testOverrides.projectIds ?? + ( + await getConfigWithOverrides({ + restApiArgs, + }) + ).boundedContext.projectIds + ); } private async getAllowedDatasourceIds({ @@ -67,8 +66,14 @@ class ResourceAccessChecker { }: { restApiArgs: RestApiArgs; }): Promise | null> { - await this.setBoundedContext({ restApiArgs }); - return this._allowedDatasourceIds!; + return ( + this._testOverrides.datasourceIds ?? + ( + await getConfigWithOverrides({ + restApiArgs, + }) + ).boundedContext.datasourceIds + ); } private async getAllowedWorkbookIds({ @@ -76,8 +81,14 @@ class ResourceAccessChecker { }: { restApiArgs: RestApiArgs; }): Promise | null> { - await this.setBoundedContext({ restApiArgs }); - return this._allowedWorkbookIds!; + return ( + this._testOverrides.workbookIds ?? + ( + await getConfigWithOverrides({ + restApiArgs, + }) + ).boundedContext.workbookIds + ); } private async getAllowedTags({ @@ -85,8 +96,14 @@ class ResourceAccessChecker { }: { restApiArgs: RestApiArgs; }): Promise | null> { - await this.setBoundedContext({ restApiArgs }); - return this._allowedTags!; + return ( + this._testOverrides.tags ?? + ( + await getConfigWithOverrides({ + restApiArgs, + }) + ).boundedContext.tags + ); } async isDatasourceAllowed({ From 5a2a853066e511e6b4f597fa7540c9269bcf3062 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Mon, 9 Feb 2026 12:16:28 -0800 Subject: [PATCH 03/18] Create OverrideableConfig class --- src/config.test.ts | 1350 +++++++---------- src/config.ts | 171 +-- src/overrideableConfig.test.ts | 348 +++++ src/overrideableConfig.ts | 182 +++ src/restApiInstance.ts | 25 +- src/server/express.ts | 3 +- src/tools/contentExploration/searchContent.ts | 32 +- .../contentExploration/searchContentUtils.ts | 2 +- .../getDatasourceMetadata.ts | 23 +- src/tools/listDatasources/listDatasources.ts | 36 +- src/tools/pulse/constrainPulseDefinitions.ts | 2 +- src/tools/pulse/constrainPulseMetrics.ts | 2 +- .../listAllPulseMetricDefinitions.ts | 31 +- .../listPulseMetricSubscriptions.ts | 6 +- src/tools/queryDatasource/queryDatasource.ts | 30 +- src/tools/resourceAccessChecker.ts | 13 +- src/tools/views/listViews.ts | 26 +- src/tools/workbooks/getWorkbook.ts | 25 +- src/tools/workbooks/listWorkbooks.ts | 29 +- src/utils/mcpSiteSettings.ts | 18 +- src/utils/restApiArgs.ts | 14 - 21 files changed, 1245 insertions(+), 1123 deletions(-) create mode 100644 src/overrideableConfig.test.ts create mode 100644 src/overrideableConfig.ts delete mode 100644 src/utils/restApiArgs.ts diff --git a/src/config.test.ts b/src/config.test.ts index 5409df18..95ce8dc4 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,12 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - import { exportedForTesting, ONE_HOUR_IN_MS, TEN_MINUTES_IN_MS } from './config.js'; describe('Config', () => { const { Config, parseNumber } = exportedForTesting; - const originalEnv = process.env; - const defaultEnvVars = { SERVER: 'https://test-server.com', SITE_NAME: 'test-site', @@ -16,97 +12,32 @@ describe('Config', () => { beforeEach(() => { vi.resetModules(); - process.env = { - ...originalEnv, - AUTH: undefined, - TRANSPORT: undefined, - HTTP_PORT_ENV_VAR_NAME: undefined, - PORT: undefined, - CUSTOM_PORT: undefined, - CORS_ORIGIN_CONFIG: undefined, - TRUST_PROXY_CONFIG: undefined, - SERVER: undefined, - SITE_NAME: undefined, - PAT_NAME: undefined, - PAT_VALUE: undefined, - JWT_SUB_CLAIM: undefined, - CONNECTED_APP_CLIENT_ID: undefined, - CONNECTED_APP_SECRET_ID: undefined, - CONNECTED_APP_SECRET_VALUE: undefined, - UAT_TENANT_ID: undefined, - UAT_ISSUER: undefined, - UAT_USERNAME_CLAIM: undefined, - UAT_USERNAME_CLAIM_NAME: undefined, - UAT_PRIVATE_KEY: undefined, - UAT_PRIVATE_KEY_PATH: undefined, - UAT_KEY_ID: undefined, - JWT_ADDITIONAL_PAYLOAD: undefined, - DATASOURCE_CREDENTIALS: undefined, - DEFAULT_LOG_LEVEL: undefined, - DISABLE_LOG_MASKING: undefined, - INCLUDE_TOOLS: undefined, - EXCLUDE_TOOLS: undefined, - MAX_REQUEST_TIMEOUT_MS: undefined, - MAX_RESULT_LIMIT: undefined, - MAX_RESULT_LIMITS: undefined, - DISABLE_QUERY_DATASOURCE_VALIDATION_REQUESTS: undefined, - DISABLE_METADATA_API_REQUESTS: undefined, - DISABLE_SESSION_MANAGEMENT: undefined, - ENABLE_SERVER_LOGGING: undefined, - SERVER_LOG_DIRECTORY: undefined, - INCLUDE_PROJECT_IDS: undefined, - INCLUDE_DATASOURCE_IDS: undefined, - INCLUDE_WORKBOOK_IDS: undefined, - INCLUDE_TAGS: undefined, - TABLEAU_SERVER_VERSION_CHECK_INTERVAL_IN_HOURS: undefined, - DANGEROUSLY_DISABLE_OAUTH: undefined, - OAUTH_ISSUER: undefined, - OAUTH_REDIRECT_URI: undefined, - OAUTH_LOCK_SITE: undefined, - OAUTH_JWE_PRIVATE_KEY: undefined, - OAUTH_JWE_PRIVATE_KEY_PATH: undefined, - OAUTH_JWE_PRIVATE_KEY_PASSPHRASE: undefined, - OAUTH_CIMD_DNS_SERVERS: undefined, - OAUTH_ACCESS_TOKEN_TIMEOUT_MS: undefined, - OAUTH_AUTHORIZATION_CODE_TIMEOUT_MS: undefined, - OAUTH_REFRESH_TOKEN_TIMEOUT_MS: undefined, - OAUTH_CLIENT_ID_SECRET_PAIRS: undefined, - }; }); afterEach(() => { - process.env = { ...originalEnv }; + vi.unstubAllEnvs(); }); it('should throw error when SERVER is missing', () => { - process.env = { - ...process.env, - SERVER: undefined, - SITE_NAME: 'test-site', - }; + vi.stubEnv('SERVER', undefined); + vi.stubEnv('SITE_NAME', 'test-site'); expect(() => new Config()).toThrow('The environment variable SERVER is not set'); }); it('should accept HTTP URLs for SERVER', () => { - process.env = { - ...process.env, - SERVER: 'http://foo.com', - PAT_NAME: 'test-pat-name', - PAT_VALUE: 'test-pat-value', - SITE_NAME: 'test-site', - }; + vi.stubEnv('SERVER', 'http://foo.com'); + vi.stubEnv('PAT_NAME', 'test-pat-name'); + vi.stubEnv('PAT_VALUE', 'test-pat-value'); + vi.stubEnv('SITE_NAME', 'test-site'); const config = new Config(); expect(config.server).toBe('http://foo.com'); }); it('should throw error when SERVER is not HTTP/HTTPS', () => { - process.env = { - ...process.env, - SERVER: 'gopher://foo.com', - SITE_NAME: 'test-site', - }; + vi.stubEnv('SERVER', 'gopher://foo.com'); + vi.stubEnv('SITE_NAME', 'test-site'); expect(() => new Config()).toThrow( 'The environment variable SERVER must start with "http://" or "https://": gopher://foo.com', @@ -114,11 +45,8 @@ describe('Config', () => { }); it('should throw error when SERVER is not a valid URL', () => { - process.env = { - ...process.env, - SERVER: 'https://', - SITE_NAME: 'test-site', - }; + vi.stubEnv('SERVER', 'https://'); + vi.stubEnv('SITE_NAME', 'test-site'); expect(() => new Config()).toThrow( 'The environment variable SERVER is not a valid URL: https:// -- Invalid URL', @@ -126,47 +54,38 @@ describe('Config', () => { }); it('should set siteName to empty string when SITE_NAME is "${user_config.site_name}"', () => { - process.env = { - ...process.env, - SERVER: 'https://test-server.com', - PAT_NAME: 'test-pat-name', - PAT_VALUE: 'test-pat-value', - SITE_NAME: '${user_config.site_name}', - }; + vi.stubEnv('SERVER', 'https://test-server.com'); + vi.stubEnv('PAT_NAME', 'test-pat-name'); + vi.stubEnv('PAT_VALUE', 'test-pat-value'); + vi.stubEnv('SITE_NAME', '${user_config.site_name}'); const config = new Config(); expect(config.siteName).toBe(''); }); it('should throw error when PAT_NAME is missing', () => { - process.env = { - ...process.env, - SERVER: 'https://test-server.com', - SITE_NAME: 'test-site', - PAT_NAME: undefined, - PAT_VALUE: 'test-pat-value', - }; + vi.stubEnv('SERVER', 'https://test-server.com'); + vi.stubEnv('SITE_NAME', 'test-site'); + vi.stubEnv('PAT_NAME', undefined); + vi.stubEnv('PAT_VALUE', 'test-pat-value'); expect(() => new Config()).toThrow('The environment variable PAT_NAME is not set'); }); it('should throw error when PAT_VALUE is missing', () => { - process.env = { - ...process.env, - SERVER: 'https://test-server.com', - SITE_NAME: 'test-site', - PAT_NAME: 'test-pat-name', - PAT_VALUE: undefined, - }; + vi.stubEnv('SERVER', 'https://test-server.com'); + vi.stubEnv('SITE_NAME', 'test-site'); + vi.stubEnv('PAT_NAME', 'test-pat-name'); + vi.stubEnv('PAT_VALUE', undefined); expect(() => new Config()).toThrow('The environment variable PAT_VALUE is not set'); }); it('should configure PAT authentication when PAT credentials are provided', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); const config = new Config(); expect(config.patName).toBe('test-pat-name'); @@ -175,413 +94,271 @@ describe('Config', () => { }); it('should set default log level to debug when not specified', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); const config = new Config(); expect(config.defaultLogLevel).toBe('debug'); }); it('should set custom log level when specified', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - DEFAULT_LOG_LEVEL: 'info', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('DEFAULT_LOG_LEVEL', 'info'); const config = new Config(); expect(config.defaultLogLevel).toBe('info'); }); it('should set disableLogMasking to false by default', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); const config = new Config(); expect(config.disableLogMasking).toBe(false); }); it('should set disableLogMasking to true when specified', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - DISABLE_LOG_MASKING: 'true', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('DISABLE_LOG_MASKING', 'true'); const config = new Config(); expect(config.disableLogMasking).toBe(true); }); it('should set maxRequestTimeoutMs to the default value when not specified', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); const config = new Config(); expect(config.maxRequestTimeoutMs).toBe(10 * 60 * 1000); }); it('should set maxRequestTimeoutMs to the specified value when specified', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - MAX_REQUEST_TIMEOUT_MS: '123456', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('MAX_REQUEST_TIMEOUT_MS', '123456'); const config = new Config(); expect(config.maxRequestTimeoutMs).toBe(123456); }); it('should set maxRequestTimeoutMs to the default value when specified as a non-number', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - MAX_REQUEST_TIMEOUT_MS: 'abc', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('MAX_REQUEST_TIMEOUT_MS', 'abc'); const config = new Config(); expect(config.maxRequestTimeoutMs).toBe(TEN_MINUTES_IN_MS); }); it('should set maxRequestTimeoutMs to the default value when specified as a negative number', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - MAX_REQUEST_TIMEOUT_MS: '-100', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('MAX_REQUEST_TIMEOUT_MS', '-100'); const config = new Config(); expect(config.maxRequestTimeoutMs).toBe(TEN_MINUTES_IN_MS); }); it('should set maxRequestTimeoutMs to the default value when specified as a number greater than one hour', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - MAX_REQUEST_TIMEOUT_MS: `${ONE_HOUR_IN_MS + 1}`, - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('MAX_REQUEST_TIMEOUT_MS', `${ONE_HOUR_IN_MS + 1}`); const config = new Config(); expect(config.maxRequestTimeoutMs).toBe(TEN_MINUTES_IN_MS); }); - it('should set disableQueryDatasourceValidationRequests to false by default', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - }; - - const config = new Config(); - expect(config.disableQueryDatasourceValidationRequests).toBe(false); - }); - - it('should set disableQueryDatasourceValidationRequests to true when specified', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - DISABLE_QUERY_DATASOURCE_VALIDATION_REQUESTS: 'true', - }; - - const config = new Config(); - expect(config.disableQueryDatasourceValidationRequests).toBe(true); - }); - - it('should set disableMetadataApiRequests to false by default', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - }; - - const config = new Config(); - expect(config.disableMetadataApiRequests).toBe(false); - }); - - it('should set disableMetadataApiRequests to true when specified', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - DISABLE_METADATA_API_REQUESTS: 'true', - }; - - const config = new Config(); - expect(config.disableMetadataApiRequests).toBe(true); - }); - it('should set disableSessionManagement to false by default', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); const config = new Config(); expect(config.disableSessionManagement).toBe(false); }); - it('should set disableMetadataApiRequests to true when specified', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - DISABLE_SESSION_MANAGEMENT: 'true', - }; + it('should set disableSessionManagement to true when specified', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('DISABLE_SESSION_MANAGEMENT', 'true'); const config = new Config(); expect(config.disableSessionManagement).toBe(true); }); it('should default transport to stdio when not specified', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); const config = new Config(); expect(config.transport).toBe('stdio'); }); it('should set transport to http when specified', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - TRANSPORT: 'http', - DANGEROUSLY_DISABLE_OAUTH: 'true', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('TRANSPORT', 'http'); + vi.stubEnv('DANGEROUSLY_DISABLE_OAUTH', 'true'); const config = new Config(); expect(config.transport).toBe('http'); }); it('should set tableauServerVersionCheckIntervalInHours to default when not specified', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - TABLEAU_SERVER_VERSION_CHECK_INTERVAL_IN_HOURS: undefined, - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('TABLEAU_SERVER_VERSION_CHECK_INTERVAL_IN_HOURS', undefined); const config = new Config(); expect(config.tableauServerVersionCheckIntervalInHours).toBe(1); }); it('should set tableauServerVersionCheckIntervalInHours to the specified value when specified', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - TABLEAU_SERVER_VERSION_CHECK_INTERVAL_IN_HOURS: '2', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('TABLEAU_SERVER_VERSION_CHECK_INTERVAL_IN_HOURS', '2'); const config = new Config(); expect(config.tableauServerVersionCheckIntervalInHours).toBe(2); }); - describe('Tool filtering', () => { - it('should set empty arrays for includeTools and excludeTools when not specified', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - }; - - const config = new Config(); - expect(config.includeTools).toEqual([]); - expect(config.excludeTools).toEqual([]); - }); - - it('should parse INCLUDE_TOOLS into an array of valid tool names', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - INCLUDE_TOOLS: 'query-datasource,get-datasource-metadata', - }; - - const config = new Config(); - expect(config.includeTools).toEqual(['query-datasource', 'get-datasource-metadata']); - }); - - it('should parse INCLUDE_TOOLS into an array of valid tool names when tool group names are used', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - INCLUDE_TOOLS: 'query-datasource,workbook', - }; - - const config = new Config(); - expect(config.includeTools).toEqual(['query-datasource', 'list-workbooks', 'get-workbook']); - }); - - it('should parse EXCLUDE_TOOLS into an array of valid tool names', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - EXCLUDE_TOOLS: 'query-datasource', - }; - - const config = new Config(); - expect(config.excludeTools).toEqual(['query-datasource']); - }); - - it('should parse EXCLUDE_TOOLS into an array of valid tool names when tool group names are used', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - EXCLUDE_TOOLS: 'query-datasource,workbook', - }; - - const config = new Config(); - expect(config.excludeTools).toEqual(['query-datasource', 'list-workbooks', 'get-workbook']); - }); - - it('should filter out invalid tool names from INCLUDE_TOOLS', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - INCLUDE_TOOLS: 'query-datasource,order-hamburgers', - }; - - const config = new Config(); - expect(config.includeTools).toEqual(['query-datasource']); - }); - - it('should filter out invalid tool names from EXCLUDE_TOOLS', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - EXCLUDE_TOOLS: 'query-datasource,order-hamburgers', - }; - - const config = new Config(); - expect(config.excludeTools).toEqual(['query-datasource']); - }); - - it('should throw error when both INCLUDE_TOOLS and EXCLUDE_TOOLS are specified', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - INCLUDE_TOOLS: 'query-datasource', - EXCLUDE_TOOLS: 'get-datasource-metadata', - }; - - expect(() => new Config()).toThrow('Cannot include and exclude tools simultaneously'); - }); - - it('should throw error when both INCLUDE_TOOLS and EXCLUDE_TOOLS are specified with tool group names', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - INCLUDE_TOOLS: 'datasource', - EXCLUDE_TOOLS: 'workbook', - }; - expect(() => new Config()).toThrow('Cannot include and exclude tools simultaneously'); - }); - }); - describe('HTTP server config parsing', () => { it('should set sslKey to default when SSL_KEY is not set', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); const config = new Config(); expect(config.sslKey).toBe(''); }); it('should set sslKey to the specified value when SSL_KEY is set', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - SSL_KEY: 'path/to/ssl-key.pem', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('SSL_KEY', 'path/to/ssl-key.pem'); const config = new Config(); expect(config.sslKey).toBe('path/to/ssl-key.pem'); }); it('should set sslCert to default when SSL_CERT is not set', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); const config = new Config(); expect(config.sslCert).toBe(''); }); it('should set sslCert to the specified value when SSL_CERT is set', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - SSL_CERT: 'path/to/ssl-cert.pem', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('SSL_CERT', 'path/to/ssl-cert.pem'); const config = new Config(); expect(config.sslCert).toBe('path/to/ssl-cert.pem'); }); it('should set httpPort to default when HTTP_PORT_ENV_VAR_NAME and PORT are not set', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); const config = new Config(); expect(config.httpPort).toBe(3927); }); it('should set httpPort to the value of PORT when set', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - PORT: '8080', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('PORT', '8080'); const config = new Config(); expect(config.httpPort).toBe(8080); }); it('should set httpPort to the value of the environment variable specified by HTTP_PORT_ENV_VAR_NAME when set', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - HTTP_PORT_ENV_VAR_NAME: 'CUSTOM_PORT', - CUSTOM_PORT: '41664', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('HTTP_PORT_ENV_VAR_NAME', 'CUSTOM_PORT'); + vi.stubEnv('CUSTOM_PORT', '41664'); const config = new Config(); expect(config.httpPort).toBe(41664); }); it('should set httpPort to default when HTTP_PORT_ENV_VAR_NAME is set and custom port is not set', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - HTTP_PORT_ENV_VAR_NAME: 'CUSTOM_PORT', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('HTTP_PORT_ENV_VAR_NAME', 'CUSTOM_PORT'); const config = new Config(); expect(config.httpPort).toBe(3927); }); it('should set httpPort to default when PORT is set to an invalid value', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - PORT: 'invalid', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('PORT', 'invalid'); const config = new Config(); expect(config.httpPort).toBe(3927); }); it('should set httpPort to default when HTTP_PORT_ENV_VAR_NAME is set and custom port is invalid', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - HTTP_PORT_ENV_VAR_NAME: 'CUSTOM_PORT', - CUSTOM_PORT: 'invalid', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('HTTP_PORT_ENV_VAR_NAME', 'CUSTOM_PORT'); + vi.stubEnv('CUSTOM_PORT', 'invalid'); const config = new Config(); expect(config.httpPort).toBe(3927); @@ -590,76 +367,76 @@ describe('Config', () => { describe('CORS origin config parsing', () => { it('should set corsOriginConfig to true when CORS_ORIGIN_CONFIG is not set', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); const config = new Config(); expect(config.corsOriginConfig).toBe(true); }); it('should set corsOriginConfig to true when CORS_ORIGIN_CONFIG is "true"', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - CORS_ORIGIN_CONFIG: 'true', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('CORS_ORIGIN_CONFIG', 'true'); const config = new Config(); expect(config.corsOriginConfig).toBe(true); }); it('should set corsOriginConfig to "*" when CORS_ORIGIN_CONFIG is "*"', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - CORS_ORIGIN_CONFIG: '*', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('CORS_ORIGIN_CONFIG', '*'); const config = new Config(); expect(config.corsOriginConfig).toBe('*'); }); it('should set corsOriginConfig to false when CORS_ORIGIN_CONFIG is "false"', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - CORS_ORIGIN_CONFIG: 'false', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('CORS_ORIGIN_CONFIG', 'false'); const config = new Config(); expect(config.corsOriginConfig).toBe(false); }); it('should set corsOriginConfig to the specified origin when CORS_ORIGIN_CONFIG is a valid URL', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - CORS_ORIGIN_CONFIG: 'https://example.com:8080', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('CORS_ORIGIN_CONFIG', 'https://example.com:8080'); const config = new Config(); expect(config.corsOriginConfig).toBe('https://example.com:8080'); }); it('should set corsOriginConfig to the specified origins when CORS_ORIGIN_CONFIG is an array of URLs', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - CORS_ORIGIN_CONFIG: '["https://example.com", "https://example.org"]', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('CORS_ORIGIN_CONFIG', '["https://example.com", "https://example.org"]'); const config = new Config(); expect(config.corsOriginConfig).toEqual(['https://example.com', 'https://example.org']); }); it('should throw error when CORS_ORIGIN_CONFIG is not a valid URL', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - CORS_ORIGIN_CONFIG: 'invalid', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('CORS_ORIGIN_CONFIG', 'invalid'); expect(() => new Config()).toThrow( 'The environment variable CORS_ORIGIN_CONFIG is not a valid URL: invalid', @@ -667,11 +444,11 @@ describe('Config', () => { }); it('should throw error when CORS_ORIGIN_CONFIG is not a valid array of URLs', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - CORS_ORIGIN_CONFIG: '["https://example.com", "invalid"]', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('CORS_ORIGIN_CONFIG', '["https://example.com", "invalid"]'); expect(() => new Config()).toThrow( 'The environment variable CORS_ORIGIN_CONFIG is not a valid array of URLs: ["https://example.com", "invalid"]', @@ -681,54 +458,54 @@ describe('Config', () => { describe('Trust proxy config parsing', () => { it('should set trustProxyConfig to null when TRUST_PROXY_CONFIG is not set', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); const config = new Config(); expect(config.trustProxyConfig).toBe(null); }); it('should set trustProxyConfig to true when TRUST_PROXY_CONFIG is "true"', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - TRUST_PROXY_CONFIG: 'true', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('TRUST_PROXY_CONFIG', 'true'); const config = new Config(); expect(config.trustProxyConfig).toBe(true); }); it('should set trustProxyConfig to false when TRUST_PROXY_CONFIG is "false"', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - TRUST_PROXY_CONFIG: 'false', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('TRUST_PROXY_CONFIG', 'false'); const config = new Config(); expect(config.trustProxyConfig).toBe(false); }); it('should set trustProxyConfig to the specified number when TRUST_PROXY_CONFIG is a valid number', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - TRUST_PROXY_CONFIG: '1', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('TRUST_PROXY_CONFIG', '1'); const config = new Config(); expect(config.trustProxyConfig).toBe(1); }); it('should set trustProxyConfig to the specified string when TRUST_PROXY_CONFIG is a valid string', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - TRUST_PROXY_CONFIG: 'loopback, linklocal, uniquelocal', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('TRUST_PROXY_CONFIG', 'loopback, linklocal, uniquelocal'); const config = new Config(); expect(config.trustProxyConfig).toBe('loopback, linklocal, uniquelocal'); @@ -746,10 +523,18 @@ describe('Config', () => { } as const; it('should configure direct-trust authentication when all required variables are provided', () => { - process.env = { - ...process.env, - ...defaultDirectTrustEnvVars, - }; + vi.stubEnv('SERVER', defaultDirectTrustEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultDirectTrustEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultDirectTrustEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultDirectTrustEnvVars.PAT_VALUE); + vi.stubEnv('AUTH', defaultDirectTrustEnvVars.AUTH); + vi.stubEnv('JWT_SUB_CLAIM', defaultDirectTrustEnvVars.JWT_SUB_CLAIM); + vi.stubEnv('CONNECTED_APP_CLIENT_ID', defaultDirectTrustEnvVars.CONNECTED_APP_CLIENT_ID); + vi.stubEnv('CONNECTED_APP_SECRET_ID', defaultDirectTrustEnvVars.CONNECTED_APP_SECRET_ID); + vi.stubEnv( + 'CONNECTED_APP_SECRET_VALUE', + defaultDirectTrustEnvVars.CONNECTED_APP_SECRET_VALUE, + ); const config = new Config(); expect(config.auth).toBe('direct-trust'); @@ -761,32 +546,54 @@ describe('Config', () => { }); it('should set jwtAdditionalPayload to the specified value when JWT_ADDITIONAL_PAYLOAD is set', () => { - process.env = { - ...process.env, - ...defaultDirectTrustEnvVars, - JWT_ADDITIONAL_PAYLOAD: '{"custom":"payload"}', - }; + vi.stubEnv('SERVER', defaultDirectTrustEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultDirectTrustEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultDirectTrustEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultDirectTrustEnvVars.PAT_VALUE); + vi.stubEnv('AUTH', defaultDirectTrustEnvVars.AUTH); + vi.stubEnv('JWT_SUB_CLAIM', defaultDirectTrustEnvVars.JWT_SUB_CLAIM); + vi.stubEnv('CONNECTED_APP_CLIENT_ID', defaultDirectTrustEnvVars.CONNECTED_APP_CLIENT_ID); + vi.stubEnv('CONNECTED_APP_SECRET_ID', defaultDirectTrustEnvVars.CONNECTED_APP_SECRET_ID); + vi.stubEnv( + 'CONNECTED_APP_SECRET_VALUE', + defaultDirectTrustEnvVars.CONNECTED_APP_SECRET_VALUE, + ); + vi.stubEnv('JWT_ADDITIONAL_PAYLOAD', '{"custom":"payload"}'); const config = new Config(); expect(JSON.parse(config.jwtAdditionalPayload)).toEqual({ custom: 'payload' }); }); it('should throw error when JWT_SUB_CLAIM is missing for direct-trust auth', () => { - process.env = { - ...process.env, - ...defaultDirectTrustEnvVars, - JWT_SUB_CLAIM: undefined, - }; + vi.stubEnv('SERVER', defaultDirectTrustEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultDirectTrustEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultDirectTrustEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultDirectTrustEnvVars.PAT_VALUE); + vi.stubEnv('AUTH', defaultDirectTrustEnvVars.AUTH); + vi.stubEnv('JWT_SUB_CLAIM', undefined); + vi.stubEnv('CONNECTED_APP_CLIENT_ID', defaultDirectTrustEnvVars.CONNECTED_APP_CLIENT_ID); + vi.stubEnv('CONNECTED_APP_SECRET_ID', defaultDirectTrustEnvVars.CONNECTED_APP_SECRET_ID); + vi.stubEnv( + 'CONNECTED_APP_SECRET_VALUE', + defaultDirectTrustEnvVars.CONNECTED_APP_SECRET_VALUE, + ); expect(() => new Config()).toThrow('The environment variable JWT_SUB_CLAIM is not set'); }); it('should throw error when CONNECTED_APP_CLIENT_ID is missing for direct-trust auth', () => { - process.env = { - ...process.env, - ...defaultDirectTrustEnvVars, - CONNECTED_APP_CLIENT_ID: undefined, - }; + vi.stubEnv('SERVER', defaultDirectTrustEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultDirectTrustEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultDirectTrustEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultDirectTrustEnvVars.PAT_VALUE); + vi.stubEnv('AUTH', defaultDirectTrustEnvVars.AUTH); + vi.stubEnv('JWT_SUB_CLAIM', defaultDirectTrustEnvVars.JWT_SUB_CLAIM); + vi.stubEnv('CONNECTED_APP_CLIENT_ID', undefined); + vi.stubEnv('CONNECTED_APP_SECRET_ID', defaultDirectTrustEnvVars.CONNECTED_APP_SECRET_ID); + vi.stubEnv( + 'CONNECTED_APP_SECRET_VALUE', + defaultDirectTrustEnvVars.CONNECTED_APP_SECRET_VALUE, + ); expect(() => new Config()).toThrow( 'The environment variable CONNECTED_APP_CLIENT_ID is not set', @@ -794,11 +601,18 @@ describe('Config', () => { }); it('should throw error when CONNECTED_APP_SECRET_ID is missing for direct-trust auth', () => { - process.env = { - ...process.env, - ...defaultDirectTrustEnvVars, - CONNECTED_APP_SECRET_ID: undefined, - }; + vi.stubEnv('SERVER', defaultDirectTrustEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultDirectTrustEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultDirectTrustEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultDirectTrustEnvVars.PAT_VALUE); + vi.stubEnv('AUTH', defaultDirectTrustEnvVars.AUTH); + vi.stubEnv('JWT_SUB_CLAIM', defaultDirectTrustEnvVars.JWT_SUB_CLAIM); + vi.stubEnv('CONNECTED_APP_CLIENT_ID', defaultDirectTrustEnvVars.CONNECTED_APP_CLIENT_ID); + vi.stubEnv('CONNECTED_APP_SECRET_ID', undefined); + vi.stubEnv( + 'CONNECTED_APP_SECRET_VALUE', + defaultDirectTrustEnvVars.CONNECTED_APP_SECRET_VALUE, + ); expect(() => new Config()).toThrow( 'The environment variable CONNECTED_APP_SECRET_ID is not set', @@ -806,11 +620,15 @@ describe('Config', () => { }); it('should throw error when CONNECTED_APP_SECRET_VALUE is missing for direct-trust auth', () => { - process.env = { - ...process.env, - ...defaultDirectTrustEnvVars, - CONNECTED_APP_SECRET_VALUE: undefined, - }; + vi.stubEnv('SERVER', defaultDirectTrustEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultDirectTrustEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultDirectTrustEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultDirectTrustEnvVars.PAT_VALUE); + vi.stubEnv('AUTH', defaultDirectTrustEnvVars.AUTH); + vi.stubEnv('JWT_SUB_CLAIM', defaultDirectTrustEnvVars.JWT_SUB_CLAIM); + vi.stubEnv('CONNECTED_APP_CLIENT_ID', defaultDirectTrustEnvVars.CONNECTED_APP_CLIENT_ID); + vi.stubEnv('CONNECTED_APP_SECRET_ID', defaultDirectTrustEnvVars.CONNECTED_APP_SECRET_ID); + vi.stubEnv('CONNECTED_APP_SECRET_VALUE', undefined); expect(() => new Config()).toThrow( 'The environment variable CONNECTED_APP_SECRET_VALUE is not set', @@ -818,12 +636,18 @@ describe('Config', () => { }); it('should allow PAT_NAME and PAT_VALUE to be empty when AUTH is "direct-trust"', () => { - process.env = { - ...process.env, - ...defaultDirectTrustEnvVars, - PAT_NAME: undefined, - PAT_VALUE: undefined, - }; + vi.stubEnv('SERVER', defaultDirectTrustEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultDirectTrustEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', undefined); + vi.stubEnv('PAT_VALUE', undefined); + vi.stubEnv('AUTH', defaultDirectTrustEnvVars.AUTH); + vi.stubEnv('JWT_SUB_CLAIM', defaultDirectTrustEnvVars.JWT_SUB_CLAIM); + vi.stubEnv('CONNECTED_APP_CLIENT_ID', defaultDirectTrustEnvVars.CONNECTED_APP_CLIENT_ID); + vi.stubEnv('CONNECTED_APP_SECRET_ID', defaultDirectTrustEnvVars.CONNECTED_APP_SECRET_ID); + vi.stubEnv( + 'CONNECTED_APP_SECRET_VALUE', + defaultDirectTrustEnvVars.CONNECTED_APP_SECRET_VALUE, + ); const config = new Config(); expect(config.patName).toBe(''); @@ -831,11 +655,11 @@ describe('Config', () => { }); it('should allow all direct-trust fields to be empty when AUTH is not "direct-trust"', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - AUTH: 'pat', - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('AUTH', 'pat'); const config = new Config(); expect(config.auth).toBe('pat'); @@ -859,10 +683,16 @@ describe('Config', () => { } as const; it('should configure uat authentication when all required variables are provided', () => { - process.env = { - ...process.env, - ...defaultUatEnvVars, - }; + vi.stubEnv('SERVER', defaultUatEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultUatEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultUatEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultUatEnvVars.PAT_VALUE); + vi.stubEnv('AUTH', defaultUatEnvVars.AUTH); + vi.stubEnv('UAT_TENANT_ID', defaultUatEnvVars.UAT_TENANT_ID); + vi.stubEnv('UAT_ISSUER', defaultUatEnvVars.UAT_ISSUER); + vi.stubEnv('UAT_USERNAME_CLAIM', defaultUatEnvVars.UAT_USERNAME_CLAIM); + vi.stubEnv('UAT_PRIVATE_KEY', defaultUatEnvVars.UAT_PRIVATE_KEY); + vi.stubEnv('UAT_KEY_ID', defaultUatEnvVars.UAT_KEY_ID); const config = new Config(); expect(config.auth).toBe('uat'); @@ -875,55 +705,81 @@ describe('Config', () => { }); it('should fall back to JWT_SUB_CLAIM when UAT_USERNAME_CLAIM is not set', () => { - process.env = { - ...process.env, - ...defaultUatEnvVars, - UAT_USERNAME_CLAIM: undefined, - JWT_SUB_CLAIM: 'test-jwt-sub-claim', - }; + vi.stubEnv('SERVER', defaultUatEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultUatEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultUatEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultUatEnvVars.PAT_VALUE); + vi.stubEnv('AUTH', defaultUatEnvVars.AUTH); + vi.stubEnv('UAT_TENANT_ID', defaultUatEnvVars.UAT_TENANT_ID); + vi.stubEnv('UAT_ISSUER', defaultUatEnvVars.UAT_ISSUER); + vi.stubEnv('UAT_USERNAME_CLAIM', undefined); + vi.stubEnv('JWT_SUB_CLAIM', 'test-jwt-sub-claim'); + vi.stubEnv('UAT_PRIVATE_KEY', defaultUatEnvVars.UAT_PRIVATE_KEY); + vi.stubEnv('UAT_KEY_ID', defaultUatEnvVars.UAT_KEY_ID); const config = new Config(); expect(config.jwtUsername).toBe('test-jwt-sub-claim'); }); it('should set uatUsernameClaimName to the specified value when UAT_USERNAME_CLAIM_NAME is set', () => { - process.env = { - ...process.env, - ...defaultUatEnvVars, - UAT_USERNAME_CLAIM_NAME: 'test-username-claim-name', - }; + vi.stubEnv('SERVER', defaultUatEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultUatEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultUatEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultUatEnvVars.PAT_VALUE); + vi.stubEnv('AUTH', defaultUatEnvVars.AUTH); + vi.stubEnv('UAT_TENANT_ID', defaultUatEnvVars.UAT_TENANT_ID); + vi.stubEnv('UAT_ISSUER', defaultUatEnvVars.UAT_ISSUER); + vi.stubEnv('UAT_USERNAME_CLAIM', defaultUatEnvVars.UAT_USERNAME_CLAIM); + vi.stubEnv('UAT_USERNAME_CLAIM_NAME', 'test-username-claim-name'); + vi.stubEnv('UAT_PRIVATE_KEY', defaultUatEnvVars.UAT_PRIVATE_KEY); + vi.stubEnv('UAT_KEY_ID', defaultUatEnvVars.UAT_KEY_ID); const config = new Config(); expect(config.uatUsernameClaimName).toBe('test-username-claim-name'); }); it('should throw error when UAT_TENANT_ID is missing', () => { - process.env = { - ...process.env, - ...defaultUatEnvVars, - UAT_TENANT_ID: undefined, - }; + vi.stubEnv('SERVER', defaultUatEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultUatEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultUatEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultUatEnvVars.PAT_VALUE); + vi.stubEnv('AUTH', defaultUatEnvVars.AUTH); + vi.stubEnv('UAT_TENANT_ID', undefined); + vi.stubEnv('UAT_ISSUER', defaultUatEnvVars.UAT_ISSUER); + vi.stubEnv('UAT_USERNAME_CLAIM', defaultUatEnvVars.UAT_USERNAME_CLAIM); + vi.stubEnv('UAT_PRIVATE_KEY', defaultUatEnvVars.UAT_PRIVATE_KEY); + vi.stubEnv('UAT_KEY_ID', defaultUatEnvVars.UAT_KEY_ID); expect(() => new Config()).toThrow('The environment variable UAT_TENANT_ID is not set'); }); it('should throw error when UAT_ISSUER is missing', () => { - process.env = { - ...process.env, - ...defaultUatEnvVars, - UAT_ISSUER: undefined, - }; + vi.stubEnv('SERVER', defaultUatEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultUatEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultUatEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultUatEnvVars.PAT_VALUE); + vi.stubEnv('AUTH', defaultUatEnvVars.AUTH); + vi.stubEnv('UAT_TENANT_ID', defaultUatEnvVars.UAT_TENANT_ID); + vi.stubEnv('UAT_ISSUER', undefined); + vi.stubEnv('UAT_USERNAME_CLAIM', defaultUatEnvVars.UAT_USERNAME_CLAIM); + vi.stubEnv('UAT_PRIVATE_KEY', defaultUatEnvVars.UAT_PRIVATE_KEY); + vi.stubEnv('UAT_KEY_ID', defaultUatEnvVars.UAT_KEY_ID); expect(() => new Config()).toThrow('The environment variable UAT_ISSUER is not set'); }); it('should throw error when UAT_USERNAME_CLAIM is missing and JWT_SUB_CLAIM is not set', () => { - process.env = { - ...process.env, - ...defaultUatEnvVars, - UAT_USERNAME_CLAIM: undefined, - JWT_SUB_CLAIM: undefined, - }; + vi.stubEnv('SERVER', defaultUatEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultUatEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultUatEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultUatEnvVars.PAT_VALUE); + vi.stubEnv('AUTH', defaultUatEnvVars.AUTH); + vi.stubEnv('UAT_TENANT_ID', defaultUatEnvVars.UAT_TENANT_ID); + vi.stubEnv('UAT_ISSUER', defaultUatEnvVars.UAT_ISSUER); + vi.stubEnv('UAT_USERNAME_CLAIM', undefined); + vi.stubEnv('JWT_SUB_CLAIM', undefined); + vi.stubEnv('UAT_PRIVATE_KEY', defaultUatEnvVars.UAT_PRIVATE_KEY); + vi.stubEnv('UAT_KEY_ID', defaultUatEnvVars.UAT_KEY_ID); expect(() => new Config()).toThrow( 'One of the environment variables: UAT_USERNAME_CLAIM or JWT_SUB_CLAIM must be set', @@ -931,12 +787,17 @@ describe('Config', () => { }); it('should throw error when UAT_PRIVATE_KEY and UAT_PRIVATE_KEY_PATH is not set', () => { - process.env = { - ...process.env, - ...defaultUatEnvVars, - UAT_PRIVATE_KEY: undefined, - UAT_PRIVATE_KEY_PATH: undefined, - }; + vi.stubEnv('SERVER', defaultUatEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultUatEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultUatEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultUatEnvVars.PAT_VALUE); + vi.stubEnv('AUTH', defaultUatEnvVars.AUTH); + vi.stubEnv('UAT_TENANT_ID', defaultUatEnvVars.UAT_TENANT_ID); + vi.stubEnv('UAT_ISSUER', defaultUatEnvVars.UAT_ISSUER); + vi.stubEnv('UAT_USERNAME_CLAIM', defaultUatEnvVars.UAT_USERNAME_CLAIM); + vi.stubEnv('UAT_PRIVATE_KEY', undefined); + vi.stubEnv('UAT_PRIVATE_KEY_PATH', undefined); + vi.stubEnv('UAT_KEY_ID', defaultUatEnvVars.UAT_KEY_ID); expect(() => new Config()).toThrow( 'One of the environment variables: UAT_PRIVATE_KEY_PATH or UAT_PRIVATE_KEY must be set', @@ -944,12 +805,17 @@ describe('Config', () => { }); it('should throw error when UAT_PRIVATE_KEY and UAT_PRIVATE_KEY_PATH are both set', () => { - process.env = { - ...process.env, - ...defaultUatEnvVars, - UAT_PRIVATE_KEY: 'hamburgers', - UAT_PRIVATE_KEY_PATH: 'hotdogs', - }; + vi.stubEnv('SERVER', defaultUatEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultUatEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultUatEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultUatEnvVars.PAT_VALUE); + vi.stubEnv('AUTH', defaultUatEnvVars.AUTH); + vi.stubEnv('UAT_TENANT_ID', defaultUatEnvVars.UAT_TENANT_ID); + vi.stubEnv('UAT_ISSUER', defaultUatEnvVars.UAT_ISSUER); + vi.stubEnv('UAT_USERNAME_CLAIM', defaultUatEnvVars.UAT_USERNAME_CLAIM); + vi.stubEnv('UAT_PRIVATE_KEY', 'hamburgers'); + vi.stubEnv('UAT_PRIVATE_KEY_PATH', 'hotdogs'); + vi.stubEnv('UAT_KEY_ID', defaultUatEnvVars.UAT_KEY_ID); expect(() => new Config()).toThrow( 'Only one of the environment variables: UAT_PRIVATE_KEY or UAT_PRIVATE_KEY_PATH must be set', @@ -957,90 +823,6 @@ describe('Config', () => { }); }); - 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, - tags: null, - }); - }); - - it('should set boundedContext to the specified tags and 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', - INCLUDE_TAGS: 'tag1,tag2', - }; - - const config = new Config(); - expect(config.boundedContext).toEqual({ - projectIds: new Set(['123', '456']), - datasourceIds: new Set(['789', '101']), - workbookIds: new Set(['112', '113']), - tags: new Set(['tag1', 'tag2']), - }); - }); - - 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', - ); - }); - - it('should throw error when INCLUDE_TAGS is set to an empty string', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - INCLUDE_TAGS: '', - }; - - expect(() => new Config()).toThrow( - 'When set, the environment variable INCLUDE_TAGS must have at least one value', - ); - }); - }); - describe('OAuth configuration', () => { const defaultOAuthEnvVars = { ...defaultEnvVars, @@ -1067,11 +849,15 @@ describe('Config', () => { ...defaultOAuthTimeoutMs, } as const; + beforeEach(() => { + vi.stubEnv('TABLEAU_MCP_TEST', 'true'); + }); + it('should default to disabled', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - }; + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); const config = new Config(); expect(config.oauth).toEqual({ @@ -1089,32 +875,38 @@ describe('Config', () => { }); it('should enable OAuth when OAUTH_ISSUER is set', () => { - process.env = { - ...process.env, - ...defaultOAuthEnvVars, - }; + vi.stubEnv('SERVER', defaultOAuthEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultOAuthEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultOAuthEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultOAuthEnvVars.PAT_VALUE); + vi.stubEnv('OAUTH_ISSUER', defaultOAuthEnvVars.OAUTH_ISSUER); + vi.stubEnv('OAUTH_JWE_PRIVATE_KEY_PATH', defaultOAuthEnvVars.OAUTH_JWE_PRIVATE_KEY_PATH); const config = new Config(); expect(config.oauth).toEqual(defaultOAuthConfig); }); it('should disable OAuth when DANGEROUSLY_DISABLE_OAUTH is "true"', () => { - process.env = { - ...process.env, - ...defaultOAuthEnvVars, - DANGEROUSLY_DISABLE_OAUTH: 'true', - }; + vi.stubEnv('SERVER', defaultOAuthEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultOAuthEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultOAuthEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultOAuthEnvVars.PAT_VALUE); + vi.stubEnv('OAUTH_ISSUER', defaultOAuthEnvVars.OAUTH_ISSUER); + vi.stubEnv('OAUTH_JWE_PRIVATE_KEY_PATH', defaultOAuthEnvVars.OAUTH_JWE_PRIVATE_KEY_PATH); + vi.stubEnv('DANGEROUSLY_DISABLE_OAUTH', 'true'); const config = new Config(); expect(config.oauth.enabled).toEqual(false); }); it('should set redirectUri to the specified value when OAUTH_REDIRECT_URI is set', () => { - process.env = { - ...process.env, - ...defaultOAuthEnvVars, - OAUTH_REDIRECT_URI: 'https://example.com/CustomCallback', - }; + vi.stubEnv('SERVER', defaultOAuthEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultOAuthEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultOAuthEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultOAuthEnvVars.PAT_VALUE); + vi.stubEnv('OAUTH_ISSUER', defaultOAuthEnvVars.OAUTH_ISSUER); + vi.stubEnv('OAUTH_JWE_PRIVATE_KEY_PATH', defaultOAuthEnvVars.OAUTH_JWE_PRIVATE_KEY_PATH); + vi.stubEnv('OAUTH_REDIRECT_URI', 'https://example.com/CustomCallback'); const config = new Config(); expect(config.oauth).toEqual({ @@ -1124,11 +916,13 @@ describe('Config', () => { }); it('should set redirectUri to the default value when OAUTH_REDIRECT_URI is not set', () => { - process.env = { - ...process.env, - ...defaultOAuthEnvVars, - OAUTH_REDIRECT_URI: '', - }; + vi.stubEnv('SERVER', defaultOAuthEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultOAuthEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultOAuthEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultOAuthEnvVars.PAT_VALUE); + vi.stubEnv('OAUTH_ISSUER', defaultOAuthEnvVars.OAUTH_ISSUER); + vi.stubEnv('OAUTH_JWE_PRIVATE_KEY_PATH', defaultOAuthEnvVars.OAUTH_JWE_PRIVATE_KEY_PATH); + vi.stubEnv('OAUTH_REDIRECT_URI', ''); const config = new Config(); expect(config.oauth).toEqual({ @@ -1138,11 +932,13 @@ describe('Config', () => { }); it('should set lockSite to the specified value when OAUTH_LOCK_SITE is set', () => { - process.env = { - ...process.env, - ...defaultOAuthEnvVars, - OAUTH_LOCK_SITE: 'false', - }; + vi.stubEnv('SERVER', defaultOAuthEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultOAuthEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultOAuthEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultOAuthEnvVars.PAT_VALUE); + vi.stubEnv('OAUTH_ISSUER', defaultOAuthEnvVars.OAUTH_ISSUER); + vi.stubEnv('OAUTH_JWE_PRIVATE_KEY_PATH', defaultOAuthEnvVars.OAUTH_JWE_PRIVATE_KEY_PATH); + vi.stubEnv('OAUTH_LOCK_SITE', 'false'); const config = new Config(); expect(config.oauth).toEqual({ @@ -1152,12 +948,13 @@ describe('Config', () => { }); it('should set jwePrivateKey to the specified value when OAUTH_JWE_PRIVATE_KEY is set', () => { - process.env = { - ...process.env, - ...defaultOAuthEnvVars, - OAUTH_JWE_PRIVATE_KEY: 'hamburgers', - OAUTH_JWE_PRIVATE_KEY_PATH: '', - }; + vi.stubEnv('SERVER', defaultOAuthEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultOAuthEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultOAuthEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultOAuthEnvVars.PAT_VALUE); + vi.stubEnv('OAUTH_ISSUER', defaultOAuthEnvVars.OAUTH_ISSUER); + vi.stubEnv('OAUTH_JWE_PRIVATE_KEY', 'hamburgers'); + vi.stubEnv('OAUTH_JWE_PRIVATE_KEY_PATH', ''); const config = new Config(); expect(config.oauth).toEqual({ @@ -1169,11 +966,13 @@ describe('Config', () => { }); it('should set authzCodeTimeoutMs to the specified value when OAUTH_AUTHORIZATION_CODE_TIMEOUT_MS is set', () => { - process.env = { - ...process.env, - ...defaultOAuthEnvVars, - OAUTH_AUTHORIZATION_CODE_TIMEOUT_MS: '5678', - }; + vi.stubEnv('SERVER', defaultOAuthEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultOAuthEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultOAuthEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultOAuthEnvVars.PAT_VALUE); + vi.stubEnv('OAUTH_ISSUER', defaultOAuthEnvVars.OAUTH_ISSUER); + vi.stubEnv('OAUTH_JWE_PRIVATE_KEY_PATH', defaultOAuthEnvVars.OAUTH_JWE_PRIVATE_KEY_PATH); + vi.stubEnv('OAUTH_AUTHORIZATION_CODE_TIMEOUT_MS', '5678'); const config = new Config(); expect(config.oauth).toEqual({ @@ -1183,11 +982,13 @@ describe('Config', () => { }); it('should set accessTokenTimeoutMs to the specified value when OAUTH_ACCESS_TOKEN_TIMEOUT_MS is set', () => { - process.env = { - ...process.env, - ...defaultOAuthEnvVars, - OAUTH_ACCESS_TOKEN_TIMEOUT_MS: '1234', - }; + vi.stubEnv('SERVER', defaultOAuthEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultOAuthEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultOAuthEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultOAuthEnvVars.PAT_VALUE); + vi.stubEnv('OAUTH_ISSUER', defaultOAuthEnvVars.OAUTH_ISSUER); + vi.stubEnv('OAUTH_JWE_PRIVATE_KEY_PATH', defaultOAuthEnvVars.OAUTH_JWE_PRIVATE_KEY_PATH); + vi.stubEnv('OAUTH_ACCESS_TOKEN_TIMEOUT_MS', '1234'); const config = new Config(); expect(config.oauth).toEqual({ @@ -1197,23 +998,26 @@ describe('Config', () => { }); it('should set refreshTokenTimeoutMs to the specified value when OAUTH_REFRESH_TOKEN_TIMEOUT_MS is set', () => { - process.env = { - ...process.env, - ...defaultOAuthEnvVars, - OAUTH_REFRESH_TOKEN_TIMEOUT_MS: '1234', - }; + vi.stubEnv('SERVER', defaultOAuthEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultOAuthEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultOAuthEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultOAuthEnvVars.PAT_VALUE); + vi.stubEnv('OAUTH_ISSUER', defaultOAuthEnvVars.OAUTH_ISSUER); + vi.stubEnv('OAUTH_JWE_PRIVATE_KEY_PATH', defaultOAuthEnvVars.OAUTH_JWE_PRIVATE_KEY_PATH); + vi.stubEnv('OAUTH_REFRESH_TOKEN_TIMEOUT_MS', '1234'); const config = new Config(); expect(config.oauth.refreshTokenTimeoutMs).toBe(1234); }); it('should throw error when TRANSPORT is "http" and OAUTH_ISSUER is not set', () => { - process.env = { - ...process.env, - ...defaultOAuthEnvVars, - TRANSPORT: 'http', - OAUTH_ISSUER: undefined, - }; + vi.stubEnv('SERVER', defaultOAuthEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultOAuthEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultOAuthEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultOAuthEnvVars.PAT_VALUE); + vi.stubEnv('TRANSPORT', 'http'); + vi.stubEnv('OAUTH_ISSUER', undefined); + vi.stubEnv('OAUTH_JWE_PRIVATE_KEY_PATH', defaultOAuthEnvVars.OAUTH_JWE_PRIVATE_KEY_PATH); expect(() => new Config()).toThrow( 'OAUTH_ISSUER must be set when TRANSPORT is "http" unless DANGEROUSLY_DISABLE_OAUTH is "true"', @@ -1221,11 +1025,12 @@ describe('Config', () => { }); it('should throw error when OAUTH_JWE_PRIVATE_KEY and OAUTH_JWE_PRIVATE_KEY_PATH is not set', () => { - process.env = { - ...process.env, - ...defaultOAuthEnvVars, - OAUTH_JWE_PRIVATE_KEY_PATH: '', - }; + vi.stubEnv('SERVER', defaultOAuthEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultOAuthEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultOAuthEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultOAuthEnvVars.PAT_VALUE); + vi.stubEnv('OAUTH_ISSUER', defaultOAuthEnvVars.OAUTH_ISSUER); + vi.stubEnv('OAUTH_JWE_PRIVATE_KEY_PATH', ''); expect(() => new Config()).toThrow( 'One of the environment variables: OAUTH_JWE_PRIVATE_KEY_PATH or OAUTH_JWE_PRIVATE_KEY must be set', @@ -1233,12 +1038,13 @@ describe('Config', () => { }); it('should throw error when OAUTH_JWE_PRIVATE_KEY and OAUTH_JWE_PRIVATE_KEY_PATH are both set', () => { - process.env = { - ...process.env, - ...defaultOAuthEnvVars, - OAUTH_JWE_PRIVATE_KEY: 'hamburgers', - OAUTH_JWE_PRIVATE_KEY_PATH: 'hotdogs', - }; + vi.stubEnv('SERVER', defaultOAuthEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultOAuthEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultOAuthEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultOAuthEnvVars.PAT_VALUE); + vi.stubEnv('OAUTH_ISSUER', defaultOAuthEnvVars.OAUTH_ISSUER); + vi.stubEnv('OAUTH_JWE_PRIVATE_KEY', 'hamburgers'); + vi.stubEnv('OAUTH_JWE_PRIVATE_KEY_PATH', 'hotdogs'); expect(() => new Config()).toThrow( 'Only one of the environment variables: OAUTH_JWE_PRIVATE_KEY or OAUTH_JWE_PRIVATE_KEY_PATH must be set', @@ -1246,23 +1052,26 @@ describe('Config', () => { }); it('should throw error when AUTH is "oauth" and OAUTH_ISSUER is not set', () => { - process.env = { - ...process.env, - ...defaultOAuthEnvVars, - AUTH: 'oauth', - OAUTH_ISSUER: '', - }; + vi.stubEnv('SERVER', defaultOAuthEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultOAuthEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultOAuthEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultOAuthEnvVars.PAT_VALUE); + vi.stubEnv('AUTH', 'oauth'); + vi.stubEnv('OAUTH_ISSUER', ''); + vi.stubEnv('OAUTH_JWE_PRIVATE_KEY_PATH', defaultOAuthEnvVars.OAUTH_JWE_PRIVATE_KEY_PATH); expect(() => new Config()).toThrow('When AUTH is "oauth", OAUTH_ISSUER must be set'); }); it('should throw error when AUTH is "oauth" and DANGEROUSLY_DISABLE_OAUTH is set', () => { - process.env = { - ...process.env, - ...defaultOAuthEnvVars, - AUTH: 'oauth', - DANGEROUSLY_DISABLE_OAUTH: 'true', - }; + vi.stubEnv('SERVER', defaultOAuthEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultOAuthEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultOAuthEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultOAuthEnvVars.PAT_VALUE); + vi.stubEnv('AUTH', 'oauth'); + vi.stubEnv('OAUTH_ISSUER', defaultOAuthEnvVars.OAUTH_ISSUER); + vi.stubEnv('OAUTH_JWE_PRIVATE_KEY_PATH', defaultOAuthEnvVars.OAUTH_JWE_PRIVATE_KEY_PATH); + vi.stubEnv('DANGEROUSLY_DISABLE_OAUTH', 'true'); expect(() => new Config()).toThrow( 'When AUTH is "oauth", DANGEROUSLY_DISABLE_OAUTH cannot be "true"', @@ -1270,44 +1079,50 @@ describe('Config', () => { }); it('should default transport to "http" when OAUTH_ISSUER is set', () => { - process.env = { - ...process.env, - ...defaultOAuthEnvVars, - TRANSPORT: undefined, - }; + vi.stubEnv('SERVER', defaultOAuthEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultOAuthEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultOAuthEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultOAuthEnvVars.PAT_VALUE); + vi.stubEnv('TRANSPORT', undefined); + vi.stubEnv('OAUTH_ISSUER', defaultOAuthEnvVars.OAUTH_ISSUER); + vi.stubEnv('OAUTH_JWE_PRIVATE_KEY_PATH', defaultOAuthEnvVars.OAUTH_JWE_PRIVATE_KEY_PATH); const config = new Config(); expect(config.transport).toBe('http'); }); it('should default auth to "oauth" when OAUTH_ISSUER is set', () => { - process.env = { - ...process.env, - ...defaultOAuthEnvVars, - }; + vi.stubEnv('SERVER', defaultOAuthEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultOAuthEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultOAuthEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultOAuthEnvVars.PAT_VALUE); + vi.stubEnv('OAUTH_ISSUER', defaultOAuthEnvVars.OAUTH_ISSUER); + vi.stubEnv('OAUTH_JWE_PRIVATE_KEY_PATH', defaultOAuthEnvVars.OAUTH_JWE_PRIVATE_KEY_PATH); const config = new Config(); expect(config.auth).toBe('oauth'); }); it('should throw error when transport is stdio and auth is "oauth"', () => { - process.env = { - ...process.env, - ...defaultOAuthEnvVars, - TRANSPORT: 'stdio', - }; + vi.stubEnv('SERVER', defaultOAuthEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultOAuthEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultOAuthEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultOAuthEnvVars.PAT_VALUE); + vi.stubEnv('TRANSPORT', 'stdio'); + vi.stubEnv('OAUTH_ISSUER', defaultOAuthEnvVars.OAUTH_ISSUER); + vi.stubEnv('OAUTH_JWE_PRIVATE_KEY_PATH', defaultOAuthEnvVars.OAUTH_JWE_PRIVATE_KEY_PATH); expect(() => new Config()).toThrow('TRANSPORT must be "http" when OAUTH_ISSUER is set'); }); it('should allow PAT_NAME and PAT_VALUE to be empty when AUTH is "oauth"', () => { - process.env = { - ...process.env, - ...defaultOAuthEnvVars, - PAT_NAME: undefined, - PAT_VALUE: undefined, - AUTH: 'oauth', - }; + vi.stubEnv('SERVER', defaultOAuthEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultOAuthEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', undefined); + vi.stubEnv('PAT_VALUE', undefined); + vi.stubEnv('AUTH', 'oauth'); + vi.stubEnv('OAUTH_ISSUER', defaultOAuthEnvVars.OAUTH_ISSUER); + vi.stubEnv('OAUTH_JWE_PRIVATE_KEY_PATH', defaultOAuthEnvVars.OAUTH_JWE_PRIVATE_KEY_PATH); const config = new Config(); expect(config.patName).toBe(''); @@ -1315,23 +1130,26 @@ describe('Config', () => { }); it('should allow SITE_NAME to be empty when AUTH is "oauth"', () => { - process.env = { - ...process.env, - ...defaultOAuthEnvVars, - AUTH: 'oauth', - SITE_NAME: '', - }; + vi.stubEnv('SERVER', defaultOAuthEnvVars.SERVER); + vi.stubEnv('SITE_NAME', ''); + vi.stubEnv('PAT_NAME', defaultOAuthEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultOAuthEnvVars.PAT_VALUE); + vi.stubEnv('AUTH', 'oauth'); + vi.stubEnv('OAUTH_ISSUER', defaultOAuthEnvVars.OAUTH_ISSUER); + vi.stubEnv('OAUTH_JWE_PRIVATE_KEY_PATH', defaultOAuthEnvVars.OAUTH_JWE_PRIVATE_KEY_PATH); const config = new Config(); expect(config.siteName).toBe(''); }); it('should set clientIdSecretPairs to the specified value when OAUTH_CLIENT_ID_SECRET_PAIRS is set', () => { - process.env = { - ...process.env, - ...defaultOAuthEnvVars, - OAUTH_CLIENT_ID_SECRET_PAIRS: 'client1:secret1,client2:secret2', - }; + vi.stubEnv('SERVER', defaultOAuthEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultOAuthEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultOAuthEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultOAuthEnvVars.PAT_VALUE); + vi.stubEnv('OAUTH_ISSUER', defaultOAuthEnvVars.OAUTH_ISSUER); + vi.stubEnv('OAUTH_JWE_PRIVATE_KEY_PATH', defaultOAuthEnvVars.OAUTH_JWE_PRIVATE_KEY_PATH); + vi.stubEnv('OAUTH_CLIENT_ID_SECRET_PAIRS', 'client1:secret1,client2:secret2'); const config = new Config(); expect(config.oauth.clientIdSecretPairs).toEqual({ @@ -1341,11 +1159,13 @@ describe('Config', () => { }); it('should set dnsServers to the specified value when OAUTH_CIMD_DNS_SERVERS is set', () => { - process.env = { - ...process.env, - ...defaultOAuthEnvVars, - OAUTH_CIMD_DNS_SERVERS: '8.8.8.8,8.8.4.4', - }; + vi.stubEnv('SERVER', defaultOAuthEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultOAuthEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultOAuthEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultOAuthEnvVars.PAT_VALUE); + vi.stubEnv('OAUTH_ISSUER', defaultOAuthEnvVars.OAUTH_ISSUER); + vi.stubEnv('OAUTH_JWE_PRIVATE_KEY_PATH', defaultOAuthEnvVars.OAUTH_JWE_PRIVATE_KEY_PATH); + vi.stubEnv('OAUTH_CIMD_DNS_SERVERS', '8.8.8.8,8.8.4.4'); const config = new Config(); expect(config.oauth.dnsServers).toEqual(['8.8.8.8', '8.8.4.4']); @@ -1448,102 +1268,4 @@ describe('Config', () => { expect(result).toBe(42); }); }); - - describe('Max results limit parsing', () => { - it('should return null when MAX_RESULT_LIMIT and MAX_RESULT_LIMITS are not set', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - }; - - expect(new Config().getMaxResultLimit('query-datasource')).toBeNull(); - }); - - it('should return the max result limit when MAX_RESULT_LIMITS has a single tool', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - MAX_RESULT_LIMITS: 'query-datasource:100', - }; - - expect(new Config().getMaxResultLimit('query-datasource')).toEqual(100); - }); - - it('should return the max result limit when MAX_RESULT_LIMITS has a single tool group', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - MAX_RESULT_LIMITS: 'datasource:200', - }; - - expect(new Config().getMaxResultLimit('query-datasource')).toEqual(200); - }); - - it('should return the max result limit for the tool when a tool and a tool group are both specified', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - MAX_RESULT_LIMITS: 'query-datasource:100,datasource:200', - }; - - expect(new Config().getMaxResultLimit('query-datasource')).toEqual(100); - expect(new Config().getMaxResultLimit('list-datasources')).toEqual(200); - }); - - it('should fallback to MAX_RESULT_LIMIT when a tool-specific max result limit is not set', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - MAX_RESULT_LIMITS: 'query-datasource:100', - MAX_RESULT_LIMIT: '300', - }; - - expect(new Config().getMaxResultLimit('query-datasource')).toEqual(100); - expect(new Config().getMaxResultLimit('list-datasources')).toEqual(300); - }); - - it('should return null when MAX_RESULT_LIMITS has a non-number', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - MAX_RESULT_LIMITS: 'query-datasource:abc', - }; - - const config = new Config(); - expect(config.getMaxResultLimit('query-datasource')).toBe(null); - }); - - it('should return null when MAX_RESULT_LIMIT is specified as a non-number', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - MAX_RESULT_LIMIT: 'abc', - }; - - const config = new Config(); - expect(config.getMaxResultLimit('query-datasource')).toBe(null); - }); - - it('should return null when MAX_RESULT_LIMITS has a negative number', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - MAX_RESULT_LIMITS: 'query-datasource:-100', - }; - - const config = new Config(); - expect(config.getMaxResultLimit('query-datasource')).toBe(null); - }); - - it('should return null when MAX_RESULT_LIMIT is specified as a negative number', () => { - process.env = { - ...process.env, - ...defaultEnvVars, - MAX_RESULT_LIMIT: '-100', - }; - - const config = new Config(); - expect(config.getMaxResultLimit('query-datasource')).toBe(null); - }); - }); }); diff --git a/src/config.ts b/src/config.ts index 1dc3fdc1..41ae5861 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,9 +2,7 @@ import { CorsOptions } from 'cors'; import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; -import { ProcessEnvEx } from '../types/process-env.js'; import { isTelemetryProvider, providerConfigSchema, TelemetryConfig } from './telemetry/types.js'; -import { isToolGroupName, isToolName, toolGroups, ToolName } from './tools/toolName.js'; import { isTransport, TransportName } from './transports.js'; import { getDirname } from './utils/getDirname.js'; import invariant from './utils/invariant.js'; @@ -24,45 +22,7 @@ function isAuthType(auth: unknown): auth is AuthType { return authTypes.some((type) => type === auth); } -export const overrideableEnvironmentVariables = [ - 'INCLUDE_TOOLS', - 'EXCLUDE_TOOLS', - 'INCLUDE_PROJECT_IDS', - 'INCLUDE_DATASOURCE_IDS', - 'INCLUDE_WORKBOOK_IDS', - 'INCLUDE_TAGS', - 'MAX_RESULT_LIMIT', - 'MAX_RESULT_LIMITS', - 'DISABLE_QUERY_DATASOURCE_VALIDATION_REQUESTS', - 'DISABLE_METADATA_API_REQUESTS', -] as const satisfies ReadonlyArray; - -type OverrideableEnvironmentVariable = (typeof overrideableEnvironmentVariables)[number]; -function isOverrideableEnvironmentVariable( - variable: unknown, -): variable is OverrideableEnvironmentVariable { - return overrideableEnvironmentVariables.some((v) => v === variable); -} - -function filterEnvVarsToOverrideable( - environmentVariables: Record, -): Record { - return Object.fromEntries( - Object.entries(environmentVariables).filter(([key]) => isOverrideableEnvironmentVariable(key)), - ) as Record; -} - -export type BoundedContext = { - projectIds: Set | null; - datasourceIds: Set | null; - workbookIds: Set | null; - tags: Set | null; -}; - export class Config { - private maxResultLimit: number | null; - private maxResultLimits: Map | null; - auth: AuthType; server: string; transport: TransportName; @@ -87,15 +47,10 @@ export class Config { datasourceCredentials: string; defaultLogLevel: string; disableLogMasking: boolean; - includeTools: Array; - excludeTools: Array; maxRequestTimeoutMs: number; - disableQueryDatasourceValidationRequests: boolean; - disableMetadataApiRequests: boolean; disableSessionManagement: boolean; enableServerLogging: boolean; serverLogDirectory: string; - boundedContext: BoundedContext; tableauServerVersionCheckIntervalInHours: number; enableMcpSiteSettings: boolean; oauth: { @@ -114,15 +69,8 @@ export class Config { }; telemetry: TelemetryConfig; - getMaxResultLimit(toolName: ToolName): number | null { - return this.maxResultLimits?.get(toolName) ?? this.maxResultLimit; - } - - constructor(overrides?: Record) { + constructor() { const cleansedVars = removeClaudeMcpBundleUserConfigTemplates(process.env); - const filteredVars = overrides - ? { ...cleansedVars, ...filterEnvVarsToOverrideable(overrides) } - : cleansedVars; const { AUTH: auth, @@ -151,20 +99,10 @@ export class Config { DATASOURCE_CREDENTIALS: datasourceCredentials, DEFAULT_LOG_LEVEL: defaultLogLevel, DISABLE_LOG_MASKING: disableLogMasking, - INCLUDE_TOOLS: includeTools, - EXCLUDE_TOOLS: excludeTools, MAX_REQUEST_TIMEOUT_MS: maxRequestTimeoutMs, - MAX_RESULT_LIMIT: maxResultLimit, - MAX_RESULT_LIMITS: maxResultLimits, - DISABLE_QUERY_DATASOURCE_VALIDATION_REQUESTS: disableQueryDatasourceValidationRequests, - DISABLE_METADATA_API_REQUESTS: disableMetadataApiRequests, DISABLE_SESSION_MANAGEMENT: disableSessionManagement, ENABLE_SERVER_LOGGING: enableServerLogging, SERVER_LOG_DIRECTORY: serverLogDirectory, - INCLUDE_PROJECT_IDS: includeProjectIds, - INCLUDE_DATASOURCE_IDS: includeDatasourceIds, - INCLUDE_WORKBOOK_IDS: includeWorkbookIds, - INCLUDE_TAGS: includeTags, TABLEAU_SERVER_VERSION_CHECK_INTERVAL_IN_HOURS: tableauServerVersionCheckIntervalInHours, ENABLE_MCP_SITE_SETTINGS: enableMcpSiteSettings, DANGEROUSLY_DISABLE_OAUTH: disableOauth, @@ -181,7 +119,7 @@ export class Config { OAUTH_REFRESH_TOKEN_TIMEOUT_MS: refreshTokenTimeoutMs, TELEMETRY_PROVIDER: telemetryProvider, TELEMETRY_PROVIDER_CONFIG: telemetryProviderConfig, - } = filteredVars; + } = cleansedVars; let jwtUsername = ''; @@ -199,42 +137,10 @@ export class Config { this.datasourceCredentials = datasourceCredentials ?? ''; this.defaultLogLevel = defaultLogLevel ?? 'debug'; this.disableLogMasking = disableLogMasking === 'true'; - this.disableQueryDatasourceValidationRequests = - disableQueryDatasourceValidationRequests === 'true'; - this.disableMetadataApiRequests = disableMetadataApiRequests === 'true'; + this.disableSessionManagement = disableSessionManagement === 'true'; this.enableServerLogging = enableServerLogging === 'true'; this.serverLogDirectory = serverLogDirectory || join(__dirname, 'logs'); - this.boundedContext = { - projectIds: createSetFromCommaSeparatedString(includeProjectIds), - datasourceIds: createSetFromCommaSeparatedString(includeDatasourceIds), - workbookIds: createSetFromCommaSeparatedString(includeWorkbookIds), - tags: createSetFromCommaSeparatedString(includeTags), - }; - - 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', - ); - } - - if (this.boundedContext.tags?.size === 0) { - throw new Error( - 'When set, the environment variable INCLUDE_TAGS must have at least one value', - ); - } this.tableauServerVersionCheckIntervalInHours = parseNumber( tableauServerVersionCheckIntervalInHours, @@ -359,30 +265,6 @@ export class Config { maxValue: ONE_HOUR_IN_MS, }); - const maxResultLimitNumber = maxResultLimit ? parseInt(maxResultLimit) : NaN; - this.maxResultLimit = - isNaN(maxResultLimitNumber) || maxResultLimitNumber <= 0 ? null : maxResultLimitNumber; - - this.maxResultLimits = maxResultLimits ? getMaxResultLimits(maxResultLimits) : null; - - this.includeTools = includeTools - ? includeTools.split(',').flatMap((s) => { - const v = s.trim(); - return isToolName(v) ? v : isToolGroupName(v) ? toolGroups[v] : []; - }) - : []; - - this.excludeTools = excludeTools - ? excludeTools.split(',').flatMap((s) => { - const v = s.trim(); - return isToolName(v) ? v : isToolGroupName(v) ? toolGroups[v] : []; - }) - : []; - - if (this.includeTools.length > 0 && this.excludeTools.length > 0) { - throw new Error('Cannot include and exclude tools simultaneously'); - } - if (this.auth === 'pat') { invariant(patName, 'The environment variable PAT_NAME is not set'); invariant(patValue, 'The environment variable PAT_VALUE is not set'); @@ -509,25 +391,9 @@ function getTrustProxyConfig(trustProxyConfig: string): boolean | number | strin return trustProxyConfig; } -// Creates a set from a comma-separated string of values. -// Returns null if the value is undefined. -function createSetFromCommaSeparatedString(value: string | undefined): Set | 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( +export function removeClaudeMcpBundleUserConfigTemplates( envVars: Record, ): Record { return Object.entries(envVars).reduce>((acc, [key, value]) => { @@ -540,32 +406,6 @@ function removeClaudeMcpBundleUserConfigTemplates( }, {}); } -function getMaxResultLimits(maxResultLimits: string): Map { - const map = new Map(); - if (!maxResultLimits) { - return map; - } - - maxResultLimits.split(',').forEach((curr) => { - const [toolName, maxResultLimit] = curr.split(':'); - const maxResultLimitNumber = maxResultLimit ? parseInt(maxResultLimit) : NaN; - const actualLimit = - isNaN(maxResultLimitNumber) || maxResultLimitNumber <= 0 ? null : maxResultLimitNumber; - if (isToolName(toolName)) { - map.set(toolName, actualLimit); - } else if (isToolGroupName(toolName)) { - toolGroups[toolName].forEach((toolName) => { - if (!map.has(toolName)) { - // Tool names take precedence over group names - map.set(toolName, actualLimit); - } - }); - } - }); - - return map; -} - function parseNumber( value: string | undefined, { @@ -590,8 +430,7 @@ function parseNumber( : number; } -export const getConfig = (overrides?: Record): Config => - new Config(overrides); +export const getConfig = (): Config => new Config(); export const exportedForTesting = { Config, diff --git a/src/overrideableConfig.test.ts b/src/overrideableConfig.test.ts new file mode 100644 index 00000000..f54b991e --- /dev/null +++ b/src/overrideableConfig.test.ts @@ -0,0 +1,348 @@ +import { exportedForTesting } from './overrideableConfig.js'; + +describe('OverrideableConfig', () => { + const { OverrideableConfig } = exportedForTesting; + + const defaultEnvVars = { + SERVER: 'https://test-server.com', + SITE_NAME: 'test-site', + PAT_NAME: 'test-pat-name', + PAT_VALUE: 'test-pat-value', + } as const; + + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should set disableQueryDatasourceValidationRequests to false by default', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + + const config = new OverrideableConfig({}); + expect(config.disableQueryDatasourceValidationRequests).toBe(false); + }); + + it('should set disableQueryDatasourceValidationRequests to true when specified', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('DISABLE_QUERY_DATASOURCE_VALIDATION_REQUESTS', 'true'); + + const config = new OverrideableConfig({}); + expect(config.disableQueryDatasourceValidationRequests).toBe(true); + }); + + it('should set disableMetadataApiRequests to false by default', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + + const config = new OverrideableConfig({}); + expect(config.disableMetadataApiRequests).toBe(false); + }); + + it('should set disableMetadataApiRequests to true when specified', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('DISABLE_METADATA_API_REQUESTS', 'true'); + + const config = new OverrideableConfig({}); + expect(config.disableMetadataApiRequests).toBe(true); + }); + + describe('Tool filtering', () => { + it('should set empty arrays for includeTools and excludeTools when not specified', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + + const config = new OverrideableConfig({}); + expect(config.includeTools).toEqual([]); + expect(config.excludeTools).toEqual([]); + }); + + it('should parse INCLUDE_TOOLS into an array of valid tool names', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('INCLUDE_TOOLS', 'query-datasource,get-datasource-metadata'); + + const config = new OverrideableConfig({}); + expect(config.includeTools).toEqual(['query-datasource', 'get-datasource-metadata']); + }); + + it('should parse INCLUDE_TOOLS into an array of valid tool names when tool group names are used', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('INCLUDE_TOOLS', 'query-datasource,workbook'); + + const config = new OverrideableConfig({}); + expect(config.includeTools).toEqual(['query-datasource', 'list-workbooks', 'get-workbook']); + }); + + it('should parse EXCLUDE_TOOLS into an array of valid tool names', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('EXCLUDE_TOOLS', 'query-datasource'); + + const config = new OverrideableConfig({}); + expect(config.excludeTools).toEqual(['query-datasource']); + }); + + it('should parse EXCLUDE_TOOLS into an array of valid tool names when tool group names are used', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('EXCLUDE_TOOLS', 'query-datasource,workbook'); + + const config = new OverrideableConfig({}); + expect(config.excludeTools).toEqual(['query-datasource', 'list-workbooks', 'get-workbook']); + }); + + it('should filter out invalid tool names from INCLUDE_TOOLS', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('INCLUDE_TOOLS', 'query-datasource,order-hamburgers'); + + const config = new OverrideableConfig({}); + expect(config.includeTools).toEqual(['query-datasource']); + }); + + it('should filter out invalid tool names from EXCLUDE_TOOLS', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('EXCLUDE_TOOLS', 'query-datasource,order-hamburgers'); + + const config = new OverrideableConfig({}); + expect(config.excludeTools).toEqual(['query-datasource']); + }); + + it('should throw error when both INCLUDE_TOOLS and EXCLUDE_TOOLS are specified', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('INCLUDE_TOOLS', 'query-datasource'); + vi.stubEnv('EXCLUDE_TOOLS', 'get-datasource-metadata'); + + expect(() => new OverrideableConfig({})).toThrow( + 'Cannot include and exclude tools simultaneously', + ); + }); + + it('should throw error when both INCLUDE_TOOLS and EXCLUDE_TOOLS are specified with tool group names', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('INCLUDE_TOOLS', 'datasource'); + vi.stubEnv('EXCLUDE_TOOLS', 'workbook'); + expect(() => new OverrideableConfig({})).toThrow( + 'Cannot include and exclude tools simultaneously', + ); + }); + }); + + describe('Bounded context parsing', () => { + it('should set boundedContext to null sets when no project, datasource, or workbook IDs are provided', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + + const config = new OverrideableConfig({}); + expect(config.boundedContext).toEqual({ + projectIds: null, + datasourceIds: null, + workbookIds: null, + tags: null, + }); + }); + + it('should set boundedContext to the specified tags and project, datasource, and workbook IDs when provided', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('INCLUDE_PROJECT_IDS', ' 123, 456, 123 '); // spacing is intentional here to test trimming + vi.stubEnv('INCLUDE_DATASOURCE_IDS', '789,101'); + vi.stubEnv('INCLUDE_WORKBOOK_IDS', '112,113'); + vi.stubEnv('INCLUDE_TAGS', 'tag1,tag2'); + + const config = new OverrideableConfig({}); + expect(config.boundedContext).toEqual({ + projectIds: new Set(['123', '456']), + datasourceIds: new Set(['789', '101']), + workbookIds: new Set(['112', '113']), + tags: new Set(['tag1', 'tag2']), + }); + }); + + it('should throw error when INCLUDE_PROJECT_IDS is set to an empty string', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('INCLUDE_PROJECT_IDS', ''); + + expect(() => new OverrideableConfig({})).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', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('INCLUDE_DATASOURCE_IDS', ''); + + expect(() => new OverrideableConfig({})).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', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('INCLUDE_WORKBOOK_IDS', ''); + + expect(() => new OverrideableConfig({})).toThrow( + 'When set, the environment variable INCLUDE_WORKBOOK_IDS must have at least one value', + ); + }); + + it('should throw error when INCLUDE_TAGS is set to an empty string', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('INCLUDE_TAGS', ''); + + expect(() => new OverrideableConfig({})).toThrow( + 'When set, the environment variable INCLUDE_TAGS must have at least one value', + ); + }); + }); + + describe('Max results limit parsing', () => { + it('should return null when MAX_RESULT_LIMIT and MAX_RESULT_LIMITS are not set', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + + expect(new OverrideableConfig({}).getMaxResultLimit('query-datasource')).toBeNull(); + }); + + it('should return the max result limit when MAX_RESULT_LIMITS has a single tool', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('MAX_RESULT_LIMITS', 'query-datasource:100'); + + expect(new OverrideableConfig({}).getMaxResultLimit('query-datasource')).toEqual(100); + }); + + it('should return the max result limit when MAX_RESULT_LIMITS has a single tool group', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('MAX_RESULT_LIMITS', 'datasource:200'); + + expect(new OverrideableConfig({}).getMaxResultLimit('query-datasource')).toEqual(200); + }); + + it('should return the max result limit for the tool when a tool and a tool group are both specified', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('MAX_RESULT_LIMITS', 'query-datasource:100,datasource:200'); + + expect(new OverrideableConfig({}).getMaxResultLimit('query-datasource')).toEqual(100); + expect(new OverrideableConfig({}).getMaxResultLimit('list-datasources')).toEqual(200); + }); + + it('should fallback to MAX_RESULT_LIMIT when a tool-specific max result limit is not set', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('MAX_RESULT_LIMITS', 'query-datasource:100'); + vi.stubEnv('MAX_RESULT_LIMIT', '300'); + + expect(new OverrideableConfig({}).getMaxResultLimit('query-datasource')).toEqual(100); + expect(new OverrideableConfig({}).getMaxResultLimit('list-datasources')).toEqual(300); + }); + + it('should return null when MAX_RESULT_LIMITS has a non-number', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('MAX_RESULT_LIMITS', 'query-datasource:abc'); + + const config = new OverrideableConfig({}); + expect(config.getMaxResultLimit('query-datasource')).toBe(null); + }); + + it('should return null when MAX_RESULT_LIMIT is specified as a non-number', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('MAX_RESULT_LIMIT', 'abc'); + + const config = new OverrideableConfig({}); + expect(config.getMaxResultLimit('query-datasource')).toBe(null); + }); + + it('should return null when MAX_RESULT_LIMITS has a negative number', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('MAX_RESULT_LIMITS', 'query-datasource:-100'); + + const config = new OverrideableConfig({}); + expect(config.getMaxResultLimit('query-datasource')).toBe(null); + }); + + it('should return null when MAX_RESULT_LIMIT is specified as a negative number', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('MAX_RESULT_LIMIT', '-100'); + + const config = new OverrideableConfig({}); + expect(config.getMaxResultLimit('query-datasource')).toBe(null); + }); + }); +}); diff --git a/src/overrideableConfig.ts b/src/overrideableConfig.ts new file mode 100644 index 00000000..1488974f --- /dev/null +++ b/src/overrideableConfig.ts @@ -0,0 +1,182 @@ +import { ProcessEnvEx } from '../types/process-env.js'; +import { removeClaudeMcpBundleUserConfigTemplates } from './config.js'; +import { isToolGroupName, isToolName, toolGroups, ToolName } from './tools/toolName.js'; + +const overrideableVariables = [ + 'INCLUDE_TOOLS', + 'EXCLUDE_TOOLS', + 'INCLUDE_PROJECT_IDS', + 'INCLUDE_DATASOURCE_IDS', + 'INCLUDE_WORKBOOK_IDS', + 'INCLUDE_TAGS', + 'MAX_RESULT_LIMIT', + 'MAX_RESULT_LIMITS', + 'DISABLE_QUERY_DATASOURCE_VALIDATION_REQUESTS', + 'DISABLE_METADATA_API_REQUESTS', +] as const satisfies ReadonlyArray; + +type OverrideableVariable = (typeof overrideableVariables)[number]; +function isOverrideableVariable(variable: unknown): variable is OverrideableVariable { + return overrideableVariables.some((v) => v === variable); +} + +function filterEnvVarsToOverrideable( + environmentVariables: Record, +): Record { + return Object.fromEntries( + Object.entries(environmentVariables).filter(([key]) => isOverrideableVariable(key)), + ) as Record; +} + +export type BoundedContext = { + projectIds: Set | null; + datasourceIds: Set | null; + workbookIds: Set | null; + tags: Set | null; +}; + +export class OverrideableConfig { + private maxResultLimit: number | null; + private maxResultLimits: Map | null; + + includeTools: Array; + excludeTools: Array; + + disableQueryDatasourceValidationRequests: boolean; + disableMetadataApiRequests: boolean; + + boundedContext: BoundedContext; + + getMaxResultLimit(toolName: ToolName): number | null { + return this.maxResultLimits?.get(toolName) ?? this.maxResultLimit; + } + + constructor(overrides: Record | undefined) { + const cleansedVars = removeClaudeMcpBundleUserConfigTemplates({ + ...process.env, + ...(overrides ? filterEnvVarsToOverrideable(overrides) : {}), + }); + + const { + INCLUDE_TOOLS: includeTools, + EXCLUDE_TOOLS: excludeTools, + MAX_RESULT_LIMIT: maxResultLimit, + MAX_RESULT_LIMITS: maxResultLimits, + DISABLE_QUERY_DATASOURCE_VALIDATION_REQUESTS: disableQueryDatasourceValidationRequests, + DISABLE_METADATA_API_REQUESTS: disableMetadataApiRequests, + INCLUDE_PROJECT_IDS: includeProjectIds, + INCLUDE_DATASOURCE_IDS: includeDatasourceIds, + INCLUDE_WORKBOOK_IDS: includeWorkbookIds, + INCLUDE_TAGS: includeTags, + } = cleansedVars; + + this.disableQueryDatasourceValidationRequests = + disableQueryDatasourceValidationRequests === 'true'; + this.disableMetadataApiRequests = disableMetadataApiRequests === 'true'; + + this.boundedContext = { + projectIds: createSetFromCommaSeparatedString(includeProjectIds), + datasourceIds: createSetFromCommaSeparatedString(includeDatasourceIds), + workbookIds: createSetFromCommaSeparatedString(includeWorkbookIds), + tags: createSetFromCommaSeparatedString(includeTags), + }; + + 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', + ); + } + + if (this.boundedContext.tags?.size === 0) { + throw new Error( + 'When set, the environment variable INCLUDE_TAGS must have at least one value', + ); + } + + const maxResultLimitNumber = maxResultLimit ? parseInt(maxResultLimit) : NaN; + this.maxResultLimit = + isNaN(maxResultLimitNumber) || maxResultLimitNumber <= 0 ? null : maxResultLimitNumber; + + this.maxResultLimits = maxResultLimits ? getMaxResultLimits(maxResultLimits) : null; + + this.includeTools = includeTools + ? includeTools.split(',').flatMap((s) => { + const v = s.trim(); + return isToolName(v) ? v : isToolGroupName(v) ? toolGroups[v] : []; + }) + : []; + + this.excludeTools = excludeTools + ? excludeTools.split(',').flatMap((s) => { + const v = s.trim(); + return isToolName(v) ? v : isToolGroupName(v) ? toolGroups[v] : []; + }) + : []; + + if (this.includeTools.length > 0 && this.excludeTools.length > 0) { + throw new Error('Cannot include and exclude tools simultaneously'); + } + } +} + +// Creates a set from a comma-separated string of values. +// Returns null if the value is undefined. +function createSetFromCommaSeparatedString(value: string | undefined): Set | null { + if (value === undefined) { + return null; + } + + return new Set( + value + .trim() + .split(',') + .map((id) => id.trim()) + .filter(Boolean), + ); +} + +function getMaxResultLimits(maxResultLimits: string): Map { + const map = new Map(); + if (!maxResultLimits) { + return map; + } + + maxResultLimits.split(',').forEach((curr) => { + const [toolName, maxResultLimit] = curr.split(':'); + const maxResultLimitNumber = maxResultLimit ? parseInt(maxResultLimit) : NaN; + const actualLimit = + isNaN(maxResultLimitNumber) || maxResultLimitNumber <= 0 ? null : maxResultLimitNumber; + if (isToolName(toolName)) { + map.set(toolName, actualLimit); + } else if (isToolGroupName(toolName)) { + toolGroups[toolName].forEach((toolName) => { + if (!map.has(toolName)) { + // Tool names take precedence over group names + map.set(toolName, actualLimit); + } + }); + } + }); + + return map; +} + +export const getOverrideableConfig = ( + overrides: Record | undefined, +): OverrideableConfig => new OverrideableConfig(overrides); + +export const exportedForTesting = { + OverrideableConfig, +}; diff --git a/src/restApiInstance.ts b/src/restApiInstance.ts index 86e44b01..49b38552 100644 --- a/src/restApiInstance.ts +++ b/src/restApiInstance.ts @@ -31,6 +31,15 @@ type JwtScopes = | 'tableau:insight_brief:create' | 'tableau:mcp_site_settings:read'; +export type RestApiArgs = { + config: Config; + requestId: RequestId; + server: Server; + signal: AbortSignal; + disableLogging?: boolean; + authInfo?: TableauAuthInfo; +}; + const getNewRestApiInstanceAsync = async ({ config, requestId, @@ -39,14 +48,8 @@ const getNewRestApiInstanceAsync = async ({ signal, authInfo, disableLogging, -}: { - config: Config; - requestId: RequestId; - server: Server; +}: RestApiArgs & { jwtScopes: Set; - signal: AbortSignal; - authInfo?: TableauAuthInfo; - disableLogging: boolean; }): Promise => { if (!disableLogging) { signal.addEventListener( @@ -131,15 +134,9 @@ export const useRestApi = async ({ signal, authInfo, disableLogging = false, -}: { - config: Config; - requestId: RequestId; - server: Server; +}: RestApiArgs & { jwtScopes: Array; - signal: AbortSignal; callback: (restApi: RestApi) => Promise; - authInfo?: TableauAuthInfo; - disableLogging?: boolean; }): Promise => { const restApi = await getNewRestApiInstanceAsync({ config, diff --git a/src/server/express.ts b/src/server/express.ts index ecf1b17c..424f8b2d 100644 --- a/src/server/express.ts +++ b/src/server/express.ts @@ -113,7 +113,8 @@ export async function startExpressServer({ async function createMcpServer(req: AuthenticatedRequest, res: Response): Promise { try { let transport: StreamableHTTPServerTransport; - const requestId = (isJSONRPCRequest(req.body) && req.body.id) || 'no-request-id'; + const requestId = + isJSONRPCRequest(req.body) && req.body.id !== '' ? req.body.id : 'no-request-id'; if (config.disableSessionManagement) { const server = new Server(); diff --git a/src/tools/contentExploration/searchContent.ts b/src/tools/contentExploration/searchContent.ts index 5587781f..8e9dd4ba 100644 --- a/src/tools/contentExploration/searchContent.ts +++ b/src/tools/contentExploration/searchContent.ts @@ -67,6 +67,18 @@ This tool searches across all supported content types for objects relevant to th { requestId, authInfo, signal }, ): Promise => { const config = getConfig(); + const restApiArgs = { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }; + + const configWithOverrides = await getConfigWithOverrides({ + restApiArgs, + }); + const orderByString = orderBy ? buildOrderByString(orderBy) : undefined; const filterString = filter ? buildFilterString(filter) : undefined; return await searchContentTool.logAndExecute>({ @@ -76,14 +88,14 @@ This tool searches across all supported content types for objects relevant to th callback: async () => { return new Ok( await useRestApi({ + ...restApiArgs, config, - requestId, - server, jwtScopes: ['tableau:content:read'], - signal, - authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { - const maxResultLimit = config.getMaxResultLimit(searchContentTool.name); + const maxResultLimit = configWithOverrides.getMaxResultLimit( + searchContentTool.name, + ); + const response = await restApi.contentExplorationMethods.searchContent({ terms, page: 0, @@ -97,16 +109,6 @@ This tool searches across all supported content types for objects relevant to th ); }, constrainSuccessResult: async (items) => { - const configWithOverrides = await getConfigWithOverrides({ - restApiArgs: { - config, - requestId, - server, - signal, - authInfo: getTableauAuthInfo(authInfo), - }, - }); - return constrainSearchContent({ items, boundedContext: configWithOverrides.boundedContext, diff --git a/src/tools/contentExploration/searchContentUtils.ts b/src/tools/contentExploration/searchContentUtils.ts index e8919cc7..a22f3f49 100644 --- a/src/tools/contentExploration/searchContentUtils.ts +++ b/src/tools/contentExploration/searchContentUtils.ts @@ -1,4 +1,4 @@ -import { BoundedContext } from '../../config.js'; +import { BoundedContext } from '../../overrideableConfig.js'; import { OrderBy, SearchContentFilter, diff --git a/src/tools/getDatasourceMetadata/getDatasourceMetadata.ts b/src/tools/getDatasourceMetadata/getDatasourceMetadata.ts index e436dbea..0ae110aa 100644 --- a/src/tools/getDatasourceMetadata/getDatasourceMetadata.ts +++ b/src/tools/getDatasourceMetadata/getDatasourceMetadata.ts @@ -7,6 +7,7 @@ import { useRestApi } from '../../restApiInstance.js'; import { GraphQLResponse } from '../../sdks/tableau/apis/metadataApi.js'; import { Server } from '../../server.js'; import { getTableauAuthInfo } from '../../server/oauth/getTableauAuthInfo.js'; +import { getConfigWithOverrides } from '../../utils/mcpSiteSettings.js'; import { getVizqlDataServiceDisabledError } from '../getVizqlDataServiceDisabledError.js'; import { resourceAccessChecker } from '../resourceAccessChecker.js'; import { Tool } from '../tool.js'; @@ -125,9 +126,21 @@ export const getGetDatasourceMetadataTool = (server: Server): Tool { + const restApiArgs = { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }; + + const configWithOverrides = await getConfigWithOverrides({ + restApiArgs, + }); + const isDatasourceAllowedResult = await resourceAccessChecker.isDatasourceAllowed({ datasourceLuid, - restApiArgs: { config, requestId, server, signal }, + restApiArgs, }); if (!isDatasourceAllowedResult.allowed) { @@ -138,12 +151,8 @@ export const getGetDatasourceMetadataTool = (server: Server): Tool { // Fetching metadata from VizQL Data Service API. const readMetadataResult = await restApi.vizqlDataServiceMethods.readMetadata({ @@ -156,7 +165,7 @@ export const getGetDatasourceMetadataTool = (server: Server): Tool => { const config = getConfig(); + const restApiArgs = { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }; + + const configWithOverrides = await getConfigWithOverrides({ + restApiArgs, + }); + const validatedFilter = filter ? parseAndValidateDatasourcesFilterString(filter) : undefined; return await listDatasourcesTool.logAndExecute({ requestId, @@ -91,14 +104,13 @@ export const getListDatasourcesTool = (server: Server): Tool { const datasources = await useRestApi({ - config, - requestId, - server, + ...restApiArgs, jwtScopes: ['tableau:content:read'], - signal, - authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { - const maxResultLimit = config.getMaxResultLimit(listDatasourcesTool.name); + const maxResultLimit = configWithOverrides.getMaxResultLimit( + listDatasourcesTool.name, + ); + const datasources = await paginate({ pageConfig: { pageSize, @@ -126,16 +138,6 @@ export const getListDatasourcesTool = (server: Server): Tool { - const configWithOverrides = await getConfigWithOverrides({ - restApiArgs: { - config, - requestId, - server, - signal, - authInfo: getTableauAuthInfo(authInfo), - }, - }); - return constrainDatasources({ datasources, boundedContext: configWithOverrides.boundedContext, diff --git a/src/tools/pulse/constrainPulseDefinitions.ts b/src/tools/pulse/constrainPulseDefinitions.ts index 300de13a..2b814480 100644 --- a/src/tools/pulse/constrainPulseDefinitions.ts +++ b/src/tools/pulse/constrainPulseDefinitions.ts @@ -1,4 +1,4 @@ -import { BoundedContext } from '../../config.js'; +import { BoundedContext } from '../../overrideableConfig.js'; import { PulseMetricDefinition } from '../../sdks/tableau/types/pulse.js'; import { ConstrainedResult } from '../tool.js'; diff --git a/src/tools/pulse/constrainPulseMetrics.ts b/src/tools/pulse/constrainPulseMetrics.ts index ad0abf61..0c89bf99 100644 --- a/src/tools/pulse/constrainPulseMetrics.ts +++ b/src/tools/pulse/constrainPulseMetrics.ts @@ -1,4 +1,4 @@ -import { BoundedContext } from '../../config.js'; +import { BoundedContext } from '../../overrideableConfig.js'; import { PulseMetric } from '../../sdks/tableau/types/pulse.js'; import { ConstrainedResult } from '../tool.js'; diff --git a/src/tools/pulse/listAllMetricDefinitions/listAllPulseMetricDefinitions.ts b/src/tools/pulse/listAllMetricDefinitions/listAllPulseMetricDefinitions.ts index 71690b58..2c8e0ed4 100644 --- a/src/tools/pulse/listAllMetricDefinitions/listAllPulseMetricDefinitions.ts +++ b/src/tools/pulse/listAllMetricDefinitions/listAllPulseMetricDefinitions.ts @@ -63,22 +63,31 @@ Retrieves a list of all published Pulse Metric Definitions using the Tableau RES { requestId, authInfo, signal }, ): Promise => { const config = getConfig(); + const restApiArgs = { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }; + + const configWithOverrides = await getConfigWithOverrides({ + restApiArgs, + }); + return await listAllPulseMetricDefinitionsTool.logAndExecute({ requestId, authInfo, args: { view, limit, pageSize }, callback: async () => { return await useRestApi({ - config, - requestId, - server, + ...restApiArgs, jwtScopes: ['tableau:insight_definitions_metrics:read'], - signal, - authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { - const maxResultLimit = config.getMaxResultLimit( + const maxResultLimit = configWithOverrides.getMaxResultLimit( listAllPulseMetricDefinitionsTool.name, ); + const definitions = await pulsePaginate({ config: { limit: maxResultLimit @@ -108,16 +117,6 @@ Retrieves a list of all published Pulse Metric Definitions using the Tableau RES }); }, constrainSuccessResult: async (definitions: Array) => { - const configWithOverrides = await getConfigWithOverrides({ - restApiArgs: { - config, - requestId, - server, - signal, - authInfo: getTableauAuthInfo(authInfo), - }, - }); - return constrainPulseDefinitions({ definitions, boundedContext: configWithOverrides.boundedContext, diff --git a/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts b/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts index b6d04718..2c1bcb93 100644 --- a/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts +++ b/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts @@ -1,13 +1,13 @@ import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; -import { BoundedContext, getConfig } from '../../../config.js'; -import { useRestApi } from '../../../restApiInstance.js'; +import { getConfig } from '../../../config.js'; +import { BoundedContext } from '../../../overrideableConfig.js'; +import { RestApiArgs, useRestApi } from '../../../restApiInstance.js'; import { PulseMetricSubscription } from '../../../sdks/tableau/types/pulse.js'; import { Server } from '../../../server.js'; import { getTableauAuthInfo } from '../../../server/oauth/getTableauAuthInfo.js'; import { getExceptionMessage } from '../../../utils/getExceptionMessage.js'; import { getConfigWithOverrides } from '../../../utils/mcpSiteSettings.js'; -import { RestApiArgs } from '../../../utils/restApiArgs.js'; import { ConstrainedResult, Tool } from '../../tool.js'; import { getPulseDisabledError } from '../getPulseDisabledError.js'; diff --git a/src/tools/queryDatasource/queryDatasource.ts b/src/tools/queryDatasource/queryDatasource.ts index 63bcf1c4..06ae4a6e 100644 --- a/src/tools/queryDatasource/queryDatasource.ts +++ b/src/tools/queryDatasource/queryDatasource.ts @@ -15,6 +15,7 @@ import { Server } from '../../server.js'; import { getTableauAuthInfo } from '../../server/oauth/getTableauAuthInfo.js'; import { TableauAuthInfo } from '../../server/oauth/schemas.js'; import { getResultForTableauVersion } from '../../utils/isTableauVersionAtLeast.js'; +import { getConfigWithOverrides } from '../../utils/mcpSiteSettings.js'; import { Provider } from '../../utils/provider.js'; import { getVizqlDataServiceDisabledError } from '../getVizqlDataServiceDisabledError.js'; import { resourceAccessChecker } from '../resourceAccessChecker.js'; @@ -54,15 +55,13 @@ export const getQueryDatasourceTool = ( server: Server, authInfo?: TableauAuthInfo, ): Tool => { - const config = getConfig(); - const queryDatasourceTool = new Tool({ server, name: 'query-datasource', description: new Provider( async () => await getResultForTableauVersion({ - server: config.server || authInfo?.server, + server: getConfig().server || authInfo?.server, mappings: { '2025.3.0': queryDatasourceToolDescription20253, default: queryDatasourceToolDescription, @@ -80,6 +79,19 @@ export const getQueryDatasourceTool = ( { datasourceLuid, query, limit }, { requestId, authInfo, signal }, ): Promise => { + const config = getConfig(); + const restApiArgs = { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }; + + const configWithOverrides = await getConfigWithOverrides({ + restApiArgs, + }); + return await queryDatasourceTool.logAndExecute({ requestId, authInfo, @@ -87,7 +99,7 @@ export const getQueryDatasourceTool = ( callback: async () => { const isDatasourceAllowedResult = await resourceAccessChecker.isDatasourceAllowed({ datasourceLuid, - restApiArgs: { config, requestId, server, signal }, + restApiArgs, }); if (!isDatasourceAllowedResult.allowed) { @@ -98,7 +110,7 @@ export const getQueryDatasourceTool = ( } const datasource: Datasource = { datasourceLuid }; - const maxResultLimit = config.getMaxResultLimit(queryDatasourceTool.name); + const maxResultLimit = configWithOverrides.getMaxResultLimit(queryDatasourceTool.name); const rowLimit = maxResultLimit ? Math.min(maxResultLimit, limit ?? Number.MAX_SAFE_INTEGER) : limit; @@ -132,14 +144,10 @@ export const getQueryDatasourceTool = ( }; return await useRestApi({ - config, - requestId, - server, + ...restApiArgs, jwtScopes: ['tableau:viz_data_service:read'], - signal, - authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { - if (!config.disableQueryDatasourceValidationRequests) { + if (!configWithOverrides.disableQueryDatasourceValidationRequests) { // Validate query against metadata const metadataValidationResult = await validateQueryAgainstDatasourceMetadata( query, diff --git a/src/tools/resourceAccessChecker.ts b/src/tools/resourceAccessChecker.ts index 6de00078..b46a09e9 100644 --- a/src/tools/resourceAccessChecker.ts +++ b/src/tools/resourceAccessChecker.ts @@ -1,11 +1,10 @@ -import { BoundedContext } from '../config.js'; -import { useRestApi } from '../restApiInstance.js'; +import { BoundedContext } from '../overrideableConfig.js'; +import { RestApiArgs, useRestApi } from '../restApiInstance.js'; import { DataSource } from '../sdks/tableau/types/dataSource.js'; import { View } from '../sdks/tableau/types/view.js'; import { Workbook } from '../sdks/tableau/types/workbook.js'; import { getExceptionMessage } from '../utils/getExceptionMessage.js'; import { getConfigWithOverrides } from '../utils/mcpSiteSettings.js'; -import { RestApiArgs } from '../utils/restApiArgs.js'; type AllowedResult = | { allowed: true; content?: T } @@ -216,7 +215,7 @@ class ResourceAccessChecker { try { datasource = await getDatasource(); - if (!allowedProjectIds?.has(datasource.project.id)) { + if (!allowedProjectIds.has(datasource.project.id)) { return { allowed: false, message: [ @@ -242,7 +241,7 @@ class ResourceAccessChecker { try { datasource = datasource ?? (await getDatasource()); - if (!datasource.tags?.tag?.some((tag) => allowedTags?.has(tag.label))) { + if (!datasource.tags?.tag?.some((tag) => allowedTags.has(tag.label))) { return { allowed: false, message: [ @@ -333,7 +332,7 @@ class ResourceAccessChecker { try { workbook = workbook ?? (await getWorkbook()); - if (!workbook.tags?.tag?.some((tag) => allowedTags?.has(tag.label))) { + if (!workbook.tags?.tag?.some((tag) => allowedTags.has(tag.label))) { return { allowed: false, message: [ @@ -440,7 +439,7 @@ class ResourceAccessChecker { try { view = view ?? (await getView()); - if (!view.tags?.tag?.some((tag) => allowedTags?.has(tag.label))) { + if (!view.tags?.tag?.some((tag) => allowedTags.has(tag.label))) { return { allowed: false, message: [ diff --git a/src/tools/views/listViews.ts b/src/tools/views/listViews.ts index 59f95bbc..846ca821 100644 --- a/src/tools/views/listViews.ts +++ b/src/tools/views/listViews.ts @@ -2,11 +2,13 @@ import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { Ok } from 'ts-results-es'; import { z } from 'zod'; -import { BoundedContext, getConfig } from '../../config.js'; +import { getConfig } from '../../config.js'; +import { BoundedContext } from '../../overrideableConfig.js'; import { useRestApi } from '../../restApiInstance.js'; import { View } from '../../sdks/tableau/types/view.js'; import { Server } from '../../server.js'; import { getTableauAuthInfo } from '../../server/oauth/getTableauAuthInfo.js'; +import { getConfigWithOverrides } from '../../utils/mcpSiteSettings.js'; import { paginate } from '../../utils/paginate.js'; import { genericFilterDescription } from '../genericFilterDescription.js'; import { ConstrainedResult, Tool } from '../tool.js'; @@ -71,6 +73,18 @@ export const getListViewsTool = (server: Server): Tool => { { requestId, authInfo, signal }, ): Promise => { const config = getConfig(); + const restApiArgs = { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }; + + const configWithOverrides = await getConfigWithOverrides({ + restApiArgs, + }); + const validatedFilter = filter ? parseAndValidateViewsFilterString(filter) : undefined; return await listViewsTool.logAndExecute({ @@ -80,14 +94,10 @@ export const getListViewsTool = (server: Server): Tool => { callback: async () => { return new Ok( await useRestApi({ - config, - requestId, - server, + ...restApiArgs, jwtScopes: ['tableau:content:read'], - signal, - authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { - const maxResultLimit = config.getMaxResultLimit(listViewsTool.name); + const maxResultLimit = configWithOverrides.getMaxResultLimit(listViewsTool.name); const views = await paginate({ pageConfig: { pageSize, @@ -115,7 +125,7 @@ export const getListViewsTool = (server: Server): Tool => { ); }, constrainSuccessResult: (views) => - constrainViews({ views, boundedContext: config.boundedContext }), + constrainViews({ views, boundedContext: configWithOverrides.boundedContext }), }); }, }); diff --git a/src/tools/workbooks/getWorkbook.ts b/src/tools/workbooks/getWorkbook.ts index 9224c22f..fecf7561 100644 --- a/src/tools/workbooks/getWorkbook.ts +++ b/src/tools/workbooks/getWorkbook.ts @@ -2,11 +2,13 @@ import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { Err, Ok } from 'ts-results-es'; import { z } from 'zod'; -import { BoundedContext, getConfig } from '../../config.js'; +import { getConfig } from '../../config.js'; +import { BoundedContext } from '../../overrideableConfig.js'; import { useRestApi } from '../../restApiInstance.js'; import { Workbook } from '../../sdks/tableau/types/workbook.js'; import { Server } from '../../server.js'; import { getTableauAuthInfo } from '../../server/oauth/getTableauAuthInfo.js'; +import { getConfigWithOverrides } from '../../utils/mcpSiteSettings.js'; import { resourceAccessChecker } from '../resourceAccessChecker.js'; import { ConstrainedResult, Tool } from '../tool.js'; @@ -33,6 +35,17 @@ export const getGetWorkbookTool = (server: Server): Tool => }, callback: async ({ workbookId }, { requestId, authInfo, signal }): Promise => { const config = getConfig(); + const restApiArgs = { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }; + + const configWithOverrides = await getConfigWithOverrides({ + restApiArgs, + }); return await getWorkbookTool.logAndExecute({ requestId, @@ -41,7 +54,7 @@ export const getGetWorkbookTool = (server: Server): Tool => callback: async () => { const isWorkbookAllowedResult = await resourceAccessChecker.isWorkbookAllowed({ workbookId, - restApiArgs: { config, requestId, server, signal }, + restApiArgs, }); if (!isWorkbookAllowedResult.allowed) { @@ -53,12 +66,8 @@ export const getGetWorkbookTool = (server: Server): Tool => return new Ok( await useRestApi({ - config, - requestId, - server, + ...restApiArgs, jwtScopes: ['tableau:content:read'], - signal, - authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { // Notice that we already have the workbook if it had been allowed by a project scope. const workbook = @@ -86,7 +95,7 @@ export const getGetWorkbookTool = (server: Server): Tool => ); }, constrainSuccessResult: (workbook) => - filterWorkbookViews({ workbook, boundedContext: config.boundedContext }), + filterWorkbookViews({ workbook, boundedContext: configWithOverrides.boundedContext }), getErrorText: (error: GetWorkbookError) => { switch (error.type) { case 'workbook-not-allowed': diff --git a/src/tools/workbooks/listWorkbooks.ts b/src/tools/workbooks/listWorkbooks.ts index b61d05c2..89a82d86 100644 --- a/src/tools/workbooks/listWorkbooks.ts +++ b/src/tools/workbooks/listWorkbooks.ts @@ -2,11 +2,13 @@ import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { Ok } from 'ts-results-es'; import { z } from 'zod'; -import { BoundedContext, getConfig } from '../../config.js'; +import { getConfig } from '../../config.js'; +import { BoundedContext } from '../../overrideableConfig.js'; import { useRestApi } from '../../restApiInstance.js'; import { Workbook } from '../../sdks/tableau/types/workbook.js'; import { Server } from '../../server.js'; import { getTableauAuthInfo } from '../../server/oauth/getTableauAuthInfo.js'; +import { getConfigWithOverrides } from '../../utils/mcpSiteSettings.js'; import { paginate } from '../../utils/paginate.js'; import { genericFilterDescription } from '../genericFilterDescription.js'; import { ConstrainedResult, Tool } from '../tool.js'; @@ -68,6 +70,18 @@ export const getListWorkbooksTool = (server: Server): Tool { requestId, authInfo, signal }, ): Promise => { const config = getConfig(); + const restApiArgs = { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }; + + const configWithOverrides = await getConfigWithOverrides({ + restApiArgs, + }); + const validatedFilter = filter ? parseAndValidateWorkbooksFilterString(filter) : undefined; return await listWorkbooksTool.logAndExecute({ @@ -77,14 +91,13 @@ export const getListWorkbooksTool = (server: Server): Tool callback: async () => { return new Ok( await useRestApi({ - config, - requestId, - server, + ...restApiArgs, jwtScopes: ['tableau:content:read'], - signal, - authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { - const maxResultLimit = config.getMaxResultLimit(listWorkbooksTool.name); + const maxResultLimit = configWithOverrides.getMaxResultLimit( + listWorkbooksTool.name, + ); + const workbooks = await paginate({ pageConfig: { pageSize, @@ -111,7 +124,7 @@ export const getListWorkbooksTool = (server: Server): Tool ); }, constrainSuccessResult: (workbooks) => - constrainWorkbooks({ workbooks, boundedContext: config.boundedContext }), + constrainWorkbooks({ workbooks, boundedContext: configWithOverrides.boundedContext }), }); }, }); diff --git a/src/utils/mcpSiteSettings.ts b/src/utils/mcpSiteSettings.ts index 090af386..63f96272 100644 --- a/src/utils/mcpSiteSettings.ts +++ b/src/utils/mcpSiteSettings.ts @@ -1,9 +1,9 @@ import { Config, getConfig, TEN_MINUTES_IN_MS } from '../config.js'; -import { useRestApi } from '../restApiInstance.js'; +import { getOverrideableConfig, OverrideableConfig } from '../overrideableConfig.js'; +import { RestApiArgs, useRestApi } from '../restApiInstance.js'; import { getSiteIdFromAccessToken } from '../sdks/tableau/getSiteIdFromAccessToken.js'; import { McpSiteSettings } from '../sdks/tableau/types/mcpSiteSettings.js'; import { ExpiringMap } from './expiringMap.js'; -import { RestApiArgs } from './restApiArgs.js'; type SiteNameOrSiteId = string; const mcpSiteSettingsCache = new ExpiringMap({ @@ -11,10 +11,11 @@ const mcpSiteSettingsCache = new ExpiringMap( }); async function getMcpSiteSettings({ - restApiArgs: { config, requestId, server, signal, disableLogging, authInfo }, + restApiArgs, }: { restApiArgs: RestApiArgs; }): Promise { + const { config, authInfo } = restApiArgs; if (!config.enableMcpSiteSettings) { return; } @@ -26,13 +27,8 @@ async function getMcpSiteSettings({ } const settings = await useRestApi({ - config, - requestId, - server, + ...restApiArgs, jwtScopes: ['tableau:mcp_site_settings:read'], - signal, - authInfo, - disableLogging, callback: async (restApi) => await restApi.siteMethods.getMcpSettings(), }); @@ -45,7 +41,7 @@ export async function getConfigWithOverrides({ }: { restApiArgs: Omit & Partial<{ config: Config; signal: AbortSignal }>; -}): Promise { +}): Promise { const config = restApiArgs.config ?? getConfig(); const signal = restApiArgs.signal ?? AbortSignal.timeout(config.maxRequestTimeoutMs); @@ -53,5 +49,5 @@ export async function getConfigWithOverrides({ restApiArgs: { ...restApiArgs, config, signal }, }); - return overrides ? getConfig(overrides) : config; + return getOverrideableConfig(overrides); } diff --git a/src/utils/restApiArgs.ts b/src/utils/restApiArgs.ts deleted file mode 100644 index cc73bae0..00000000 --- a/src/utils/restApiArgs.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { RequestId } from '@modelcontextprotocol/sdk/types.js'; - -import { Config } from '../config.js'; -import { Server } from '../server.js'; -import { TableauAuthInfo } from '../server/oauth/schemas.js'; - -export type RestApiArgs = { - config: Config; - requestId: RequestId; - server: Server; - signal: AbortSignal; - disableLogging?: boolean; - authInfo?: TableauAuthInfo; -}; From a1870bb42a34db36db67fbca9084969be0031ba8 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Mon, 9 Feb 2026 13:21:05 -0800 Subject: [PATCH 04/18] Add tests for override behavior --- src/overrideableConfig.test.ts | 145 +++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/src/overrideableConfig.test.ts b/src/overrideableConfig.test.ts index f54b991e..aa5c6d2a 100644 --- a/src/overrideableConfig.test.ts +++ b/src/overrideableConfig.test.ts @@ -345,4 +345,149 @@ describe('OverrideableConfig', () => { expect(config.getMaxResultLimit('query-datasource')).toBe(null); }); }); + + describe('Override behavior', () => { + it('should override INCLUDE_TOOLS', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('INCLUDE_TOOLS', 'list-views'); + + const config = new OverrideableConfig({ + INCLUDE_TOOLS: 'query-datasource', + }); + + expect(config.includeTools).toEqual(['query-datasource']); + }); + + it('should override EXCLUDE_TOOLS', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('EXCLUDE_TOOLS', 'list-views'); + + const config = new OverrideableConfig({ + EXCLUDE_TOOLS: 'get-datasource-metadata', + }); + + expect(config.excludeTools).toEqual(['get-datasource-metadata']); + }); + + it('should override INCLUDE_PROJECT_IDS', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('INCLUDE_PROJECT_IDS', '999'); + + const config = new OverrideableConfig({ + INCLUDE_PROJECT_IDS: '123,456', + }); + + expect(config.boundedContext.projectIds).toEqual(new Set(['123', '456'])); + }); + + it('should override INCLUDE_DATASOURCE_IDS', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('INCLUDE_DATASOURCE_IDS', '999'); + + const config = new OverrideableConfig({ + INCLUDE_DATASOURCE_IDS: '123,456', + }); + + expect(config.boundedContext.datasourceIds).toEqual(new Set(['123', '456'])); + }); + + it('should override INCLUDE_WORKBOOK_IDS', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('INCLUDE_WORKBOOK_IDS', '999'); + + const config = new OverrideableConfig({ + INCLUDE_WORKBOOK_IDS: '123,456', + }); + + expect(config.boundedContext.workbookIds).toEqual(new Set(['123', '456'])); + }); + + it('should override INCLUDE_TAGS', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('INCLUDE_TAGS', '999'); + + const config = new OverrideableConfig({ + INCLUDE_TAGS: '123,456', + }); + + expect(config.boundedContext.tags).toEqual(new Set(['123', '456'])); + }); + + it('should override MAX_RESULT_LIMIT', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('MAX_RESULT_LIMIT', '10'); + + const config = new OverrideableConfig({ + MAX_RESULT_LIMIT: '99', + }); + + expect(config.getMaxResultLimit('query-datasource')).toEqual(99); + }); + + it('should override MAX_RESULT_LIMITS', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('MAX_RESULT_LIMIT', '10'); + vi.stubEnv('MAX_RESULT_LIMITS', 'query-datasource:100'); + + const config = new OverrideableConfig({ + MAX_RESULT_LIMIT: '99', + MAX_RESULT_LIMITS: 'query-datasource:999', + }); + + expect(config.getMaxResultLimit('list-datasources')).toEqual(99); + expect(config.getMaxResultLimit('query-datasource')).toEqual(999); + }); + + it('should override DISABLE_QUERY_DATASOURCE_VALIDATION_REQUESTS', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('DISABLE_QUERY_DATASOURCE_VALIDATION_REQUESTS', 'false'); + + const config = new OverrideableConfig({ + DISABLE_QUERY_DATASOURCE_VALIDATION_REQUESTS: 'true', + }); + + expect(config.disableQueryDatasourceValidationRequests).toEqual(true); + }); + + it('should override DISABLE_METADATA_API_REQUESTS', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('DISABLE_METADATA_API_REQUESTS', 'false'); + + const config = new OverrideableConfig({ + DISABLE_METADATA_API_REQUESTS: 'true', + }); + + expect(config.disableMetadataApiRequests).toEqual(true); + }); + }); }); From 97cf53775659cb7c3c52fd7f1582354d865ae705 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Mon, 9 Feb 2026 13:43:49 -0800 Subject: [PATCH 05/18] Remove mock data --- src/sdks/tableau/restApi.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/sdks/tableau/restApi.ts b/src/sdks/tableau/restApi.ts index 83e7e1b5..b1f647e7 100644 --- a/src/sdks/tableau/restApi.ts +++ b/src/sdks/tableau/restApi.ts @@ -191,10 +191,7 @@ export class RestApi { get siteMethods(): { getMcpSettings: () => Promise } { return { getMcpSettings: async (): Promise => { - return { - INCLUDE_DATASOURCE_IDS: '2d935df8-fe7e-4fd8-bb14-35eb4ba31d4', - EXCLUDE_TOOLS: 'pulse', - }; + return {}; }, }; } From 7aed9cb95154f06eee86681353af0d47a6e75b72 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Mon, 9 Feb 2026 13:57:44 -0800 Subject: [PATCH 06/18] Add MCP_SITE_SETTINGS_CHECK_INTERVAL_IN_MINUTES --- src/config.test.ts | 43 ++++++++++++++++++++ src/config.ts | 11 +++++ src/scripts/createClaudeMcpBundleManifest.ts | 8 ++++ src/sdks/tableau/restApi.ts | 2 +- src/utils/mcpSiteSettings.ts | 12 ++++-- types/process-env.d.ts | 1 + 6 files changed, 72 insertions(+), 5 deletions(-) diff --git a/src/config.test.ts b/src/config.test.ts index 95ce8dc4..c540f56f 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -254,6 +254,49 @@ describe('Config', () => { expect(config.tableauServerVersionCheckIntervalInHours).toBe(2); }); + it('should set mcpSiteSettingsCheckIntervalInMinutes to default when not specified', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('MCP_SITE_SETTINGS_CHECK_INTERVAL_IN_MINUTES', undefined); + + const config = new Config(); + expect(config.mcpSiteSettingsCheckIntervalInMinutes).toBe(10); + }); + + it('should set mcpSiteSettingsCheckIntervalInMinutes to the specified value when specified', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('MCP_SITE_SETTINGS_CHECK_INTERVAL_IN_MINUTES', '2'); + + const config = new Config(); + expect(config.mcpSiteSettingsCheckIntervalInMinutes).toBe(2); + }); + + it('should set enableMcpSiteSettings to false by default', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + + const config = new Config(); + expect(config.enableMcpSiteSettings).toBe(false); + }); + + it('should set enableMcpSiteSettings to true when specified', () => { + vi.stubEnv('SERVER', defaultEnvVars.SERVER); + vi.stubEnv('SITE_NAME', defaultEnvVars.SITE_NAME); + vi.stubEnv('PAT_NAME', defaultEnvVars.PAT_NAME); + vi.stubEnv('PAT_VALUE', defaultEnvVars.PAT_VALUE); + vi.stubEnv('ENABLE_MCP_SITE_SETTINGS', 'true'); + + const config = new Config(); + expect(config.enableMcpSiteSettings).toBe(true); + }); + describe('HTTP server config parsing', () => { it('should set sslKey to default when SSL_KEY is not set', () => { vi.stubEnv('SERVER', defaultEnvVars.SERVER); diff --git a/src/config.ts b/src/config.ts index 41ae5861..ff148079 100644 --- a/src/config.ts +++ b/src/config.ts @@ -52,6 +52,7 @@ export class Config { enableServerLogging: boolean; serverLogDirectory: string; tableauServerVersionCheckIntervalInHours: number; + mcpSiteSettingsCheckIntervalInMinutes: number; enableMcpSiteSettings: boolean; oauth: { enabled: boolean; @@ -104,6 +105,7 @@ export class Config { ENABLE_SERVER_LOGGING: enableServerLogging, SERVER_LOG_DIRECTORY: serverLogDirectory, TABLEAU_SERVER_VERSION_CHECK_INTERVAL_IN_HOURS: tableauServerVersionCheckIntervalInHours, + MCP_SITE_SETTINGS_CHECK_INTERVAL_IN_MINUTES: mcpSiteSettingsCheckIntervalInMinutes, ENABLE_MCP_SITE_SETTINGS: enableMcpSiteSettings, DANGEROUSLY_DISABLE_OAUTH: disableOauth, OAUTH_ISSUER: oauthIssuer, @@ -151,6 +153,15 @@ export class Config { }, ); + this.mcpSiteSettingsCheckIntervalInMinutes = parseNumber( + mcpSiteSettingsCheckIntervalInMinutes, + { + defaultValue: 10, + minValue: 1, + maxValue: 60 * 24, // 24 hours + }, + ); + this.enableMcpSiteSettings = enableMcpSiteSettings === 'true'; const disableOauthOverride = disableOauth === 'true'; this.oauth = { diff --git a/src/scripts/createClaudeMcpBundleManifest.ts b/src/scripts/createClaudeMcpBundleManifest.ts index e904e3c5..eb5004b6 100644 --- a/src/scripts/createClaudeMcpBundleManifest.ts +++ b/src/scripts/createClaudeMcpBundleManifest.ts @@ -365,6 +365,14 @@ const envVars = { required: false, sensitive: false, }, + MCP_SITE_SETTINGS_CHECK_INTERVAL_IN_MINUTES: { + includeInUserConfig: false, + type: 'number', + title: 'MCP Site Settings Check Interval in Minutes', + description: 'The interval in minutes to check the MCP site settings.', + required: false, + sensitive: false, + }, ENABLE_MCP_SITE_SETTINGS: { includeInUserConfig: false, type: 'boolean', diff --git a/src/sdks/tableau/restApi.ts b/src/sdks/tableau/restApi.ts index b1f647e7..de55db55 100644 --- a/src/sdks/tableau/restApi.ts +++ b/src/sdks/tableau/restApi.ts @@ -187,10 +187,10 @@ export class RestApi { return this._serverMethods; } - // Temporary until we have proper site methods get siteMethods(): { getMcpSettings: () => Promise } { return { getMcpSettings: async (): Promise => { + // Remove this comment and add documentation when the "Get MCP Site Settings" REST API is available return {}; }, }; diff --git a/src/utils/mcpSiteSettings.ts b/src/utils/mcpSiteSettings.ts index 63f96272..9240c54d 100644 --- a/src/utils/mcpSiteSettings.ts +++ b/src/utils/mcpSiteSettings.ts @@ -1,4 +1,4 @@ -import { Config, getConfig, TEN_MINUTES_IN_MS } from '../config.js'; +import { Config, getConfig } from '../config.js'; import { getOverrideableConfig, OverrideableConfig } from '../overrideableConfig.js'; import { RestApiArgs, useRestApi } from '../restApiInstance.js'; import { getSiteIdFromAccessToken } from '../sdks/tableau/getSiteIdFromAccessToken.js'; @@ -6,9 +6,7 @@ import { McpSiteSettings } from '../sdks/tableau/types/mcpSiteSettings.js'; import { ExpiringMap } from './expiringMap.js'; type SiteNameOrSiteId = string; -const mcpSiteSettingsCache = new ExpiringMap({ - defaultExpirationTimeMs: TEN_MINUTES_IN_MS, -}); +let mcpSiteSettingsCache: ExpiringMap; async function getMcpSiteSettings({ restApiArgs, @@ -20,6 +18,12 @@ async function getMcpSiteSettings({ return; } + if (!mcpSiteSettingsCache) { + mcpSiteSettingsCache = new ExpiringMap({ + defaultExpirationTimeMs: config.mcpSiteSettingsCheckIntervalInMinutes * 60 * 1000, + }); + } + const cacheKey = config.siteName || getSiteIdFromAccessToken(authInfo?.accessToken ?? ''); const cachedSettings = mcpSiteSettingsCache.get(cacheKey); if (cachedSettings) { diff --git a/types/process-env.d.ts b/types/process-env.d.ts index 8ed18a36..ef1bf7a3 100644 --- a/types/process-env.d.ts +++ b/types/process-env.d.ts @@ -40,6 +40,7 @@ export interface ProcessEnvEx { INCLUDE_WORKBOOK_IDS: string | undefined; INCLUDE_TAGS: string | undefined; TABLEAU_SERVER_VERSION_CHECK_INTERVAL_IN_HOURS: string | undefined; + MCP_SITE_SETTINGS_CHECK_INTERVAL_IN_MINUTES: string | undefined; ENABLE_MCP_SITE_SETTINGS: string | undefined; DANGEROUSLY_DISABLE_OAUTH: string | undefined; OAUTH_ISSUER: string | undefined; From 980532dbaefdf2225a2243f4ba77e11c1db3c157 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Tue, 10 Feb 2026 11:32:14 -0800 Subject: [PATCH 07/18] Make RequestId optional --- src/restApiInstance.ts | 72 +++++++++---------- src/server.ts | 16 ++--- src/server/express.ts | 16 ++--- .../listPulseMetricSubscriptions.ts | 6 +- src/utils/mcpSiteSettings.ts | 3 +- src/utils/types.ts | 3 + 6 files changed, 49 insertions(+), 67 deletions(-) create mode 100644 src/utils/types.ts diff --git a/src/restApiInstance.ts b/src/restApiInstance.ts index 49b38552..cc2483d8 100644 --- a/src/restApiInstance.ts +++ b/src/restApiInstance.ts @@ -33,25 +33,28 @@ type JwtScopes = export type RestApiArgs = { config: Config; - requestId: RequestId; server: Server; signal: AbortSignal; - disableLogging?: boolean; authInfo?: TableauAuthInfo; -}; +} & ( + | { + requestId: RequestId; + disableLogging?: false; + } + | { + disableLogging: true; + } +); + +const getNewRestApiInstanceAsync = async ( + args: RestApiArgs & { + jwtScopes: Set; + }, +): Promise => { + const { config, server, jwtScopes, signal, authInfo, disableLogging } = args; -const getNewRestApiInstanceAsync = async ({ - config, - requestId, - server, - jwtScopes, - signal, - authInfo, - disableLogging, -}: RestApiArgs & { - jwtScopes: Set; -}): Promise => { if (!disableLogging) { + const { requestId } = args; signal.addEventListener( 'abort', () => { @@ -77,10 +80,16 @@ const getNewRestApiInstanceAsync = async ({ signal, requestInterceptor: disableLogging ? undefined - : [getRequestInterceptor(server, requestId), getRequestErrorInterceptor(server, requestId)], + : [ + getRequestInterceptor(server, args.requestId), + getRequestErrorInterceptor(server, args.requestId), + ], responseInterceptor: disableLogging ? undefined - : [getResponseInterceptor(server, requestId), getResponseErrorInterceptor(server, requestId)], + : [ + getResponseInterceptor(server, args.requestId), + getResponseErrorInterceptor(server, args.requestId), + ], }); if (config.auth === 'pat') { @@ -125,32 +134,21 @@ const getNewRestApiInstanceAsync = async ({ return restApi; }; -export const useRestApi = async ({ - config, - requestId, - server, - callback, - jwtScopes, - signal, - authInfo, - disableLogging = false, -}: RestApiArgs & { - jwtScopes: Array; - callback: (restApi: RestApi) => Promise; -}): Promise => { +export const useRestApi = async ( + args: RestApiArgs & { + jwtScopes: Array; + callback: (restApi: RestApi) => Promise; + }, +): Promise => { + const { callback, ...restArgs } = args; const restApi = await getNewRestApiInstanceAsync({ - config, - requestId, - server, - jwtScopes: new Set(jwtScopes), - signal, - authInfo, - disableLogging, + ...restArgs, + jwtScopes: new Set(args.jwtScopes), }); try { return await callback(restApi); } finally { - if (config.auth !== 'oauth') { + if (restArgs.config.auth !== 'oauth') { // Tableau REST sessions for 'pat' and 'direct-trust' are intentionally ephemeral. // Sessions for 'oauth' are not. Signing out would invalidate the session, // preventing the access token from being reused for subsequent requests. diff --git a/src/server.ts b/src/server.ts index bda1be3d..fe670072 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,9 +1,5 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { - InitializeRequest, - RequestId, - SetLevelRequestSchema, -} from '@modelcontextprotocol/sdk/types.js'; +import { InitializeRequest, SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import pkg from '../package.json'; import { setLogLevel } from './logging/log.js'; @@ -57,14 +53,14 @@ export class Server extends McpServer { this._clientInfo = clientInfo; } - registerTools = async (requestId?: RequestId, authInfo?: TableauAuthInfo): Promise => { + registerTools = async (authInfo?: TableauAuthInfo): Promise => { for (const { name, description, paramsSchema, annotations, callback, - } of await this._getToolsToRegister(requestId ?? 'no-request-id', authInfo)) { + } of await this._getToolsToRegister(authInfo)) { this.registerTool( name, { @@ -84,13 +80,9 @@ export class Server extends McpServer { }); }; - private _getToolsToRegister = async ( - requestId: RequestId, - authInfo?: TableauAuthInfo, - ): Promise>> => { + private _getToolsToRegister = async (authInfo?: TableauAuthInfo): Promise>> => { const config = await getConfigWithOverrides({ restApiArgs: { - requestId, server: this, authInfo, disableLogging: true, // MCP server is not connected yet so we can't send logging notifications diff --git a/src/server/express.ts b/src/server/express.ts index 424f8b2d..7b52548c 100644 --- a/src/server/express.ts +++ b/src/server/express.ts @@ -1,10 +1,5 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { - isInitializeRequest, - isJSONRPCRequest, - LoggingLevel, - RequestId, -} from '@modelcontextprotocol/sdk/types.js'; +import { isInitializeRequest, LoggingLevel } from '@modelcontextprotocol/sdk/types.js'; import cors from 'cors'; import express, { Request, RequestHandler, Response } from 'express'; import fs, { existsSync } from 'fs'; @@ -113,8 +108,6 @@ export async function startExpressServer({ async function createMcpServer(req: AuthenticatedRequest, res: Response): Promise { try { let transport: StreamableHTTPServerTransport; - const requestId = - isJSONRPCRequest(req.body) && req.body.id !== '' ? req.body.id : 'no-request-id'; if (config.disableSessionManagement) { const server = new Server(); @@ -127,7 +120,7 @@ export async function startExpressServer({ server.close(); }); - await connect(server, transport, logLevel, requestId, getTableauAuthInfo(req.auth)); + await connect(server, transport, logLevel, getTableauAuthInfo(req.auth)); } else { const sessionId = req.headers[SESSION_ID_HEADER] as string | undefined; @@ -139,7 +132,7 @@ export async function startExpressServer({ transport = createSession({ clientInfo }); const server = new Server({ clientInfo }); - await connect(server, transport, logLevel, requestId, getTableauAuthInfo(req.auth)); + await connect(server, transport, logLevel, getTableauAuthInfo(req.auth)); } else { // Invalid request res.status(400).json({ @@ -175,10 +168,9 @@ async function connect( server: Server, transport: StreamableHTTPServerTransport, logLevel: LoggingLevel, - requestId: RequestId, authInfo: TableauAuthInfo | undefined, ): Promise { - await server.registerTools(requestId, authInfo); + await server.registerTools(authInfo); server.registerRequestHandlers(); await server.connect(transport); diff --git a/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts b/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts index d822511f..b6b77b34 100644 --- a/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts +++ b/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts @@ -116,14 +116,10 @@ export async function constrainPulseMetricSubscriptions({ }; } - const { config, requestId, server, signal } = restApiArgs; try { const metricsResult = await useRestApi({ - config, - requestId, - server, + ...restApiArgs, jwtScopes: ['tableau:insight_metrics:read'], - signal, callback: async (restApi) => { return await restApi.pulseMethods.listPulseMetricsFromMetricIds( subscriptions.map((subscription) => subscription.metric_id), diff --git a/src/utils/mcpSiteSettings.ts b/src/utils/mcpSiteSettings.ts index 5d5c00f0..d19dc0a8 100644 --- a/src/utils/mcpSiteSettings.ts +++ b/src/utils/mcpSiteSettings.ts @@ -4,6 +4,7 @@ import { RestApiArgs, useRestApi } from '../restApiInstance.js'; import { McpSiteSettings } from '../sdks/tableau/types/mcpSiteSettings.js'; import { ExpiringMap } from './expiringMap.js'; import { getSiteLuidFromAccessToken } from './getSiteLuidFromAccessToken.js'; +import { DistributiveOmit } from './types.js'; type SiteNameOrSiteId = string; let mcpSiteSettingsCache: ExpiringMap; @@ -47,7 +48,7 @@ async function getMcpSiteSettings({ export async function getConfigWithOverrides({ restApiArgs, }: { - restApiArgs: Omit & + restApiArgs: DistributiveOmit & Partial<{ config: Config; signal: AbortSignal }>; }): Promise { const config = restApiArgs.config ?? getConfig(); diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 00000000..471cc536 --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1,3 @@ +// "Omit" is not distributive over unions. +// The fix is to wrap Omit in a distributive conditional type so it is applied to each union member individually. +export type DistributiveOmit = T extends any ? Omit : never; From e17426831fa5c772ffc9b5a202765c1599526e63 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Tue, 10 Feb 2026 11:42:07 -0800 Subject: [PATCH 08/18] Clean up --- src/config.ts | 16 ++++++++++++- src/overrideableConfig.ts | 2 +- .../generatePulseInsightBriefTool.ts | 24 ++++++++----------- ...neratePulseMetricValueInsightBundleTool.ts | 23 +++++++----------- ...PulseMetricDefinitionsFromDefinitionIds.ts | 22 ++++++++--------- .../listPulseMetricSubscriptions.ts | 22 +++++++---------- .../listPulseMetricsFromMetricDefinitionId.ts | 22 ++++++++--------- .../listPulseMetricsFromMetricIds.ts | 22 ++++++++--------- src/tools/views/getViewData.ts | 15 +++++++----- src/tools/views/getViewImage.ts | 15 +++++++----- ...emoveClaudeMcpBundleUserConfigTemplates.ts | 14 ----------- 11 files changed, 92 insertions(+), 105 deletions(-) delete mode 100644 src/utils/removeClaudeMcpBundleUserConfigTemplates.ts diff --git a/src/config.ts b/src/config.ts index 667c8511..dcc110d5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,7 +6,6 @@ import { isTelemetryProvider, providerConfigSchema, TelemetryConfig } from './te import { isTransport, TransportName } from './transports.js'; import { getDirname } from './utils/getDirname.js'; import invariant from './utils/invariant.js'; -import { removeClaudeMcpBundleUserConfigTemplates } from './utils/removeClaudeMcpBundleUserConfigTemplates.js'; const __dirname = getDirname(); @@ -411,6 +410,21 @@ function getTrustProxyConfig(trustProxyConfig: string): boolean | number | strin return trustProxyConfig; } +// 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}". +export function removeClaudeMcpBundleUserConfigTemplates( + envVars: Record, +): Record { + return Object.entries(envVars).reduce>((acc, [key, value]) => { + if (value?.startsWith('${user_config.')) { + acc[key] = ''; + } else { + acc[key] = value; + } + return acc; + }, {}); +} + function parseNumber( value: string | undefined, { diff --git a/src/overrideableConfig.ts b/src/overrideableConfig.ts index c95a27b2..1488974f 100644 --- a/src/overrideableConfig.ts +++ b/src/overrideableConfig.ts @@ -1,6 +1,6 @@ import { ProcessEnvEx } from '../types/process-env.js'; +import { removeClaudeMcpBundleUserConfigTemplates } from './config.js'; import { isToolGroupName, isToolName, toolGroups, ToolName } from './tools/toolName.js'; -import { removeClaudeMcpBundleUserConfigTemplates } from './utils/removeClaudeMcpBundleUserConfigTemplates.js'; const overrideableVariables = [ 'INCLUDE_TOOLS', diff --git a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts index a5ba6b3f..224b3e45 100644 --- a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts +++ b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts @@ -198,16 +198,16 @@ An insight brief is an AI-generated response to questions about Pulse metrics. I { briefRequest }, { requestId, sessionId, authInfo, signal }, ): Promise => { - const configWithOverrides = await getConfigWithOverrides({ - restApiArgs: { - requestId, - server, - signal, - authInfo: getTableauAuthInfo(authInfo), - }, - }); - const config = getConfig(); + const restApiArgs = { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }; + const configWithOverrides = await getConfigWithOverrides({ restApiArgs }); + return await generatePulseInsightBriefTool.logAndExecute< PulseInsightBriefResponse, GeneratePulseInsightBriefError @@ -242,12 +242,8 @@ An insight brief is an AI-generated response to questions about Pulse metrics. I } const result = await useRestApi({ - config, - requestId, - server, + ...restApiArgs, jwtScopes: ['tableau:insight_brief:create'], - signal, - authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => await restApi.pulseMethods.generatePulseInsightBrief(briefRequest), }); diff --git a/src/tools/pulse/generateMetricValueInsightBundle/generatePulseMetricValueInsightBundleTool.ts b/src/tools/pulse/generateMetricValueInsightBundle/generatePulseMetricValueInsightBundleTool.ts index d3497b03..b377a65e 100644 --- a/src/tools/pulse/generateMetricValueInsightBundle/generatePulseMetricValueInsightBundleTool.ts +++ b/src/tools/pulse/generateMetricValueInsightBundle/generatePulseMetricValueInsightBundleTool.ts @@ -166,15 +166,14 @@ Generate an insight bundle for the current aggregated value for Pulse Metric usi authInfo, args: { bundleRequest, bundleType }, callback: async () => { - const configWithOverrides = await getConfigWithOverrides({ - restApiArgs: { - config, - requestId, - server, - signal, - authInfo: getTableauAuthInfo(authInfo), - }, - }); + const restApiArgs = { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }; + const configWithOverrides = await getConfigWithOverrides({ restApiArgs }); const { datasourceIds } = configWithOverrides.boundedContext; if (datasourceIds) { @@ -194,12 +193,8 @@ Generate an insight bundle for the current aggregated value for Pulse Metric usi } const result = await useRestApi({ - config, - requestId, - server, + ...restApiArgs, jwtScopes: ['tableau:insights:read'], - signal, - authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => await restApi.pulseMethods.generatePulseMetricValueInsightBundle( bundleRequest, diff --git a/src/tools/pulse/listMetricDefinitionsFromDefinitionIds/listPulseMetricDefinitionsFromDefinitionIds.ts b/src/tools/pulse/listMetricDefinitionsFromDefinitionIds/listPulseMetricDefinitionsFromDefinitionIds.ts index 1dbf1dcc..b8573ec6 100644 --- a/src/tools/pulse/listMetricDefinitionsFromDefinitionIds/listPulseMetricDefinitionsFromDefinitionIds.ts +++ b/src/tools/pulse/listMetricDefinitionsFromDefinitionIds/listPulseMetricDefinitionsFromDefinitionIds.ts @@ -64,6 +64,14 @@ Retrieves a list of specific Pulse Metric Definitions using the Tableau REST API { requestId, sessionId, authInfo, signal }, ): Promise => { const config = getConfig(); + const restApiArgs = { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }; + return await listPulseMetricDefinitionsFromDefinitionIdsTool.logAndExecute({ requestId, sessionId, @@ -71,12 +79,8 @@ Retrieves a list of specific Pulse Metric Definitions using the Tableau REST API args: { metricDefinitionIds, view }, callback: async () => { return await useRestApi({ - config, - requestId, - server, + ...restApiArgs, jwtScopes: ['tableau:insight_definitions_metrics:read'], - signal, - authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { return await restApi.pulseMethods.listPulseMetricDefinitionsFromMetricDefinitionIds( metricDefinitionIds, @@ -87,13 +91,7 @@ Retrieves a list of specific Pulse Metric Definitions using the Tableau REST API }, constrainSuccessResult: async (definitions) => { const configWithOverrides = await getConfigWithOverrides({ - restApiArgs: { - config, - requestId, - server, - signal, - authInfo: getTableauAuthInfo(authInfo), - }, + restApiArgs, }); return constrainPulseDefinitions({ diff --git a/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts b/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts index b6b77b34..33a94621 100644 --- a/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts +++ b/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts @@ -38,6 +38,14 @@ Retrieves a list of published Pulse Metric Subscriptions for the current user us }, callback: async (_, { requestId, sessionId, authInfo, signal }): Promise => { const config = getConfig(); + const restApiArgs = { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }; + return await listPulseMetricSubscriptionsTool.logAndExecute({ requestId, sessionId, @@ -45,26 +53,14 @@ Retrieves a list of published Pulse Metric Subscriptions for the current user us args: {}, callback: async () => { return await useRestApi({ - config, - requestId, - server, + ...restApiArgs, jwtScopes: ['tableau:metric_subscriptions:read'], - signal, - authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { return await restApi.pulseMethods.listPulseMetricSubscriptionsForCurrentUser(); }, }); }, constrainSuccessResult: async (subscriptions) => { - const restApiArgs = { - config, - requestId, - server, - signal, - authInfo: getTableauAuthInfo(authInfo), - }; - const configWithOverrides = await getConfigWithOverrides({ restApiArgs, }); diff --git a/src/tools/pulse/listMetricsFromMetricDefinitionId/listPulseMetricsFromMetricDefinitionId.ts b/src/tools/pulse/listMetricsFromMetricDefinitionId/listPulseMetricsFromMetricDefinitionId.ts index 65728174..35205d1d 100644 --- a/src/tools/pulse/listMetricsFromMetricDefinitionId/listPulseMetricsFromMetricDefinitionId.ts +++ b/src/tools/pulse/listMetricsFromMetricDefinitionId/listPulseMetricsFromMetricDefinitionId.ts @@ -43,6 +43,14 @@ Retrieves a list of published Pulse Metrics from a Pulse Metric Definition using { requestId, sessionId, authInfo, signal }, ): Promise => { const config = getConfig(); + const restApiArgs = { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }; + return await listPulseMetricsFromMetricDefinitionIdTool.logAndExecute< Array, PulseDisabledError @@ -53,12 +61,8 @@ Retrieves a list of published Pulse Metrics from a Pulse Metric Definition using args: { pulseMetricDefinitionID }, callback: async () => { return await useRestApi({ - config, - requestId, - server, + ...restApiArgs, jwtScopes: ['tableau:insight_definitions_metrics:read'], - signal, - authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { return await restApi.pulseMethods.listPulseMetricsFromMetricDefinitionId( pulseMetricDefinitionID, @@ -68,13 +72,7 @@ Retrieves a list of published Pulse Metrics from a Pulse Metric Definition using }, constrainSuccessResult: async (metrics) => { const configWithOverrides = await getConfigWithOverrides({ - restApiArgs: { - config, - requestId, - server, - signal, - authInfo: getTableauAuthInfo(authInfo), - }, + restApiArgs, }); return constrainPulseMetrics({ diff --git a/src/tools/pulse/listMetricsFromMetricIds/listPulseMetricsFromMetricIds.ts b/src/tools/pulse/listMetricsFromMetricIds/listPulseMetricsFromMetricIds.ts index 8c21f8c3..75ffd00e 100644 --- a/src/tools/pulse/listMetricsFromMetricIds/listPulseMetricsFromMetricIds.ts +++ b/src/tools/pulse/listMetricsFromMetricIds/listPulseMetricsFromMetricIds.ts @@ -45,6 +45,14 @@ Retrieves a list of published Pulse Metrics from a list of metric IDs using the { requestId, sessionId, authInfo, signal }, ): Promise => { const config = getConfig(); + const restApiArgs = { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }; + return await listPulseMetricsFromMetricIdsTool.logAndExecute({ requestId, sessionId, @@ -52,12 +60,8 @@ Retrieves a list of published Pulse Metrics from a list of metric IDs using the args: { metricIds }, callback: async () => { return await useRestApi({ - config, - requestId, - server, + ...restApiArgs, jwtScopes: ['tableau:insight_metrics:read'], - signal, - authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { return await restApi.pulseMethods.listPulseMetricsFromMetricIds(metricIds); }, @@ -65,13 +69,7 @@ Retrieves a list of published Pulse Metrics from a list of metric IDs using the }, constrainSuccessResult: async (metrics) => { const configWithOverrides = await getConfigWithOverrides({ - restApiArgs: { - config, - requestId, - server, - signal, - authInfo: getTableauAuthInfo(authInfo), - }, + restApiArgs, }); return constrainPulseMetrics({ diff --git a/src/tools/views/getViewData.ts b/src/tools/views/getViewData.ts index d75d0792..68f5991c 100644 --- a/src/tools/views/getViewData.ts +++ b/src/tools/views/getViewData.ts @@ -36,6 +36,13 @@ export const getGetViewDataTool = (server: Server): Tool => { requestId, sessionId, authInfo, signal }, ): Promise => { const config = getConfig(); + const restApiArgs = { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }; return await getViewDataTool.logAndExecute({ requestId, @@ -45,7 +52,7 @@ export const getGetViewDataTool = (server: Server): Tool => callback: async () => { const isViewAllowedResult = await resourceAccessChecker.isViewAllowed({ viewId, - restApiArgs: { config, requestId, server, signal }, + restApiArgs, }); if (!isViewAllowedResult.allowed) { @@ -57,12 +64,8 @@ export const getGetViewDataTool = (server: Server): Tool => return new Ok( await useRestApi({ - config, - requestId, - server, + ...restApiArgs, jwtScopes: ['tableau:views:download'], - signal, - authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { return await restApi.viewsMethods.queryViewData({ viewId, diff --git a/src/tools/views/getViewImage.ts b/src/tools/views/getViewImage.ts index 0b77771d..affc1ab1 100644 --- a/src/tools/views/getViewImage.ts +++ b/src/tools/views/getViewImage.ts @@ -39,6 +39,13 @@ export const getGetViewImageTool = (server: Server): Tool = { requestId, sessionId, authInfo, signal }, ): Promise => { const config = getConfig(); + const restApiArgs = { + config, + requestId, + server, + signal, + authInfo: getTableauAuthInfo(authInfo), + }; return await getViewImageTool.logAndExecute({ requestId, @@ -48,7 +55,7 @@ export const getGetViewImageTool = (server: Server): Tool = callback: async () => { const isViewAllowedResult = await resourceAccessChecker.isViewAllowed({ viewId, - restApiArgs: { config, requestId, server, signal }, + restApiArgs, }); if (!isViewAllowedResult.allowed) { @@ -60,12 +67,8 @@ export const getGetViewImageTool = (server: Server): Tool = return new Ok( await useRestApi({ - config, - requestId, - server, + ...restApiArgs, jwtScopes: ['tableau:views:download'], - signal, - authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { return await restApi.viewsMethods.queryViewImage({ viewId, diff --git a/src/utils/removeClaudeMcpBundleUserConfigTemplates.ts b/src/utils/removeClaudeMcpBundleUserConfigTemplates.ts deleted file mode 100644 index 04ad6f1c..00000000 --- a/src/utils/removeClaudeMcpBundleUserConfigTemplates.ts +++ /dev/null @@ -1,14 +0,0 @@ -// 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}". -export function removeClaudeMcpBundleUserConfigTemplates( - envVars: Record, -): Record { - return Object.entries(envVars).reduce>((acc, [key, value]) => { - if (value?.startsWith('${user_config.')) { - acc[key] = ''; - } else { - acc[key] = value; - } - return acc; - }, {}); -} From 1f7595e9e385df466a78ff02201417b88c4993f3 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Tue, 10 Feb 2026 11:49:29 -0800 Subject: [PATCH 09/18] Update comment --- src/sdks/tableau/restApi.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/sdks/tableau/restApi.ts b/src/sdks/tableau/restApi.ts index c93b0ebb..69ab6876 100644 --- a/src/sdks/tableau/restApi.ts +++ b/src/sdks/tableau/restApi.ts @@ -190,7 +190,11 @@ export class RestApi { get siteMethods(): { getMcpSettings: () => Promise } { return { getMcpSettings: async (): Promise => { - // Remove this comment and add documentation when the "Get MCP Site Settings" REST API is available + // When the "Get MCP Site Settings" REST API is available: + // 1. Remove this comment. + // 2. Default enableMcpSiteSettings to enabled. + // 3. Add documentation for ENABLE_MCP_SITE_SETTINGS. + // 4. Add documentation for MCP_SITE_SETTINGS_CHECK_INTERVAL_IN_MINUTES. return {}; }, }; From bdc12df5b0a2483c0564541c60164d47270ccdcd Mon Sep 17 00:00:00 2001 From: Andy Young Date: Tue, 10 Feb 2026 12:13:06 -0800 Subject: [PATCH 10/18] Document type --- src/utils/mcpSiteSettings.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/utils/mcpSiteSettings.ts b/src/utils/mcpSiteSettings.ts index d19dc0a8..9c4e56c7 100644 --- a/src/utils/mcpSiteSettings.ts +++ b/src/utils/mcpSiteSettings.ts @@ -45,11 +45,14 @@ async function getMcpSiteSettings({ return settings; } +// Make "config" and "signal" optional +type GetConfigWithOverridesArgs = DistributiveOmit & + Partial<{ config: Config; signal: AbortSignal }>; + export async function getConfigWithOverrides({ restApiArgs, }: { - restApiArgs: DistributiveOmit & - Partial<{ config: Config; signal: AbortSignal }>; + restApiArgs: GetConfigWithOverridesArgs; }): Promise { const config = restApiArgs.config ?? getConfig(); const signal = restApiArgs.signal ?? AbortSignal.timeout(config.maxRequestTimeoutMs); From 6ee24b4f0a09679c86f68f9e4fbe81abc8f2beba Mon Sep 17 00:00:00 2001 From: Andy Young Date: Tue, 10 Feb 2026 12:33:13 -0800 Subject: [PATCH 11/18] Add tests back --- src/config.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/config.test.ts b/src/config.test.ts index 6ccee71a..f30f4dea 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -163,6 +163,30 @@ describe('Config', () => { expect(config.tableauServerVersionCheckIntervalInHours).toBe(2); }); + it('should set mcpSiteSettingsCheckIntervalInMinutes to default when not specified', () => { + const config = new Config(); + expect(config.mcpSiteSettingsCheckIntervalInMinutes).toBe(10); + }); + + it('should set mcpSiteSettingsCheckIntervalInMinutes to the specified value when specified', () => { + vi.stubEnv('MCP_SITE_SETTINGS_CHECK_INTERVAL_IN_MINUTES', '2'); + + const config = new Config(); + expect(config.mcpSiteSettingsCheckIntervalInMinutes).toBe(2); + }); + + it('should set enableMcpSiteSettings to false by default', () => { + const config = new Config(); + expect(config.enableMcpSiteSettings).toBe(false); + }); + + it('should set enableMcpSiteSettings to true when specified', () => { + vi.stubEnv('ENABLE_MCP_SITE_SETTINGS', 'true'); + + const config = new Config(); + expect(config.enableMcpSiteSettings).toBe(true); + }); + describe('HTTP server config parsing', () => { it('should set sslKey to default when SSL_KEY is not set', () => { const config = new Config(); From c4ced542f097876c565ba5c84996dc6f410a0054 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Tue, 10 Feb 2026 15:54:04 -0800 Subject: [PATCH 12/18] Add mcpSiteSettings tests --- src/utils/mcpSiteSettings.test.ts | 112 ++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 src/utils/mcpSiteSettings.test.ts diff --git a/src/utils/mcpSiteSettings.test.ts b/src/utils/mcpSiteSettings.test.ts new file mode 100644 index 00000000..af18ec9d --- /dev/null +++ b/src/utils/mcpSiteSettings.test.ts @@ -0,0 +1,112 @@ +import { Server } from '../server'; +import { stubDefaultEnvVars } from '../testShared'; +import { getConfigWithOverrides } from './mcpSiteSettings'; + +const mocks = vi.hoisted(() => ({ + mockGetMcpSiteSettings: vi.fn(), +})); + +vi.mock('../restApiInstance.js', () => ({ + useRestApi: vi.fn().mockImplementation(async ({ callback }) => + callback({ + siteMethods: { + getMcpSettings: mocks.mockGetMcpSiteSettings, + }, + }), + ), +})); + +describe('mcpSiteSettings', () => { + beforeEach(() => { + vi.unstubAllEnvs(); + stubDefaultEnvVars(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should not override any settings when enableMcpSiteSettings is false', async () => { + vi.stubEnv('ENABLE_MCP_SITE_SETTINGS', 'false'); + const config = await getConfigWithOverrides({ + restApiArgs: { + server: new Server(), + disableLogging: true, + }, + }); + + expect(config.includeTools).toEqual([]); + expect(config.excludeTools).toEqual([]); + expect(config.boundedContext).toEqual({ + projectIds: null, + datasourceIds: null, + workbookIds: null, + tags: null, + }); + expect(config.getMaxResultLimit('query-datasource')).toEqual(null); + expect(config.disableQueryDatasourceValidationRequests).toEqual(false); + expect(config.disableMetadataApiRequests).toEqual(false); + + expect(mocks.mockGetMcpSiteSettings).not.toHaveBeenCalled(); + }); + + it('should override settings when enableMcpSiteSettings is true', async () => { + vi.stubEnv('ENABLE_MCP_SITE_SETTINGS', 'true'); + mocks.mockGetMcpSiteSettings.mockResolvedValue({ + INCLUDE_TOOLS: 'list-views,list-datasources', + INCLUDE_PROJECT_IDS: 'project1,project2', + INCLUDE_DATASOURCE_IDS: 'datasource1,datasource2', + INCLUDE_WORKBOOK_IDS: 'workbook1,workbook2', + INCLUDE_TAGS: 'tag1,tag2', + MAX_RESULT_LIMIT: '100', + MAX_RESULT_LIMITS: 'query-datasource:100,list-datasources:20', + DISABLE_QUERY_DATASOURCE_VALIDATION_REQUESTS: 'true', + DISABLE_METADATA_API_REQUESTS: 'true', + }); + + let config = await getConfigWithOverrides({ + restApiArgs: { + server: new Server(), + disableLogging: true, + }, + }); + + expect(config.includeTools).toEqual(['list-views', 'list-datasources']); + expect(config.excludeTools).toEqual([]); + expect(config.boundedContext).toEqual({ + projectIds: new Set(['project1', 'project2']), + datasourceIds: new Set(['datasource1', 'datasource2']), + workbookIds: new Set(['workbook1', 'workbook2']), + tags: new Set(['tag1', 'tag2']), + }); + expect(config.getMaxResultLimit('query-datasource')).toEqual(100); + expect(config.getMaxResultLimit('list-datasources')).toEqual(20); + expect(config.disableQueryDatasourceValidationRequests).toEqual(true); + expect(config.disableMetadataApiRequests).toEqual(true); + + expect(mocks.mockGetMcpSiteSettings).toHaveBeenCalledTimes(1); + + // Verify cache behavior + config = await getConfigWithOverrides({ + restApiArgs: { + server: new Server(), + disableLogging: true, + }, + }); + + expect(config.includeTools).toEqual(['list-views', 'list-datasources']); + expect(config.excludeTools).toEqual([]); + expect(config.boundedContext).toEqual({ + projectIds: new Set(['project1', 'project2']), + datasourceIds: new Set(['datasource1', 'datasource2']), + workbookIds: new Set(['workbook1', 'workbook2']), + tags: new Set(['tag1', 'tag2']), + }); + expect(config.getMaxResultLimit('query-datasource')).toEqual(100); + expect(config.getMaxResultLimit('list-datasources')).toEqual(20); + expect(config.disableQueryDatasourceValidationRequests).toEqual(true); + expect(config.disableMetadataApiRequests).toEqual(true); + + expect(mocks.mockGetMcpSiteSettings).toHaveBeenCalledTimes(1); + }); +}); From d65ea8d548407c83f031ce4a72032d352c2747c7 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Tue, 10 Feb 2026 16:27:31 -0800 Subject: [PATCH 13/18] Cleanup diff --- src/config.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index dcc110d5..3c788122 100644 --- a/src/config.ts +++ b/src/config.ts @@ -74,7 +74,6 @@ export class Config { constructor() { const cleansedVars = removeClaudeMcpBundleUserConfigTemplates(process.env); - const { AUTH: auth, SERVER: server, @@ -143,7 +142,6 @@ export class Config { this.datasourceCredentials = datasourceCredentials ?? ''; this.defaultLogLevel = defaultLogLevel ?? 'debug'; this.disableLogMasking = disableLogMasking === 'true'; - this.disableSessionManagement = disableSessionManagement === 'true'; this.enableServerLogging = enableServerLogging === 'true'; this.serverLogDirectory = serverLogDirectory || join(__dirname, 'logs'); From fe2b8fbbd259c2040b1c195b3f36b0864687f91a Mon Sep 17 00:00:00 2001 From: Andy Young Date: Tue, 10 Feb 2026 16:35:29 -0800 Subject: [PATCH 14/18] Rename variable --- src/restApiInstance.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/restApiInstance.ts b/src/restApiInstance.ts index cc2483d8..a1f519a6 100644 --- a/src/restApiInstance.ts +++ b/src/restApiInstance.ts @@ -140,15 +140,16 @@ export const useRestApi = async ( callback: (restApi: RestApi) => Promise; }, ): Promise => { - const { callback, ...restArgs } = args; + const { callback, ...remaining } = args; + const { config } = remaining; const restApi = await getNewRestApiInstanceAsync({ - ...restArgs, + ...remaining, jwtScopes: new Set(args.jwtScopes), }); try { return await callback(restApi); } finally { - if (restArgs.config.auth !== 'oauth') { + if (config.auth !== 'oauth') { // Tableau REST sessions for 'pat' and 'direct-trust' are intentionally ephemeral. // Sessions for 'oauth' are not. Signing out would invalidate the session, // preventing the access token from being reused for subsequent requests. From 87c89728958257ffb81b56f5d3cc29049c147c2e Mon Sep 17 00:00:00 2001 From: Andy Young Date: Tue, 10 Feb 2026 16:36:30 -0800 Subject: [PATCH 15/18] Bump --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0378ea39..8f8a1016 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tableau/mcp-server", - "version": "1.14.6", + "version": "1.14.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tableau/mcp-server", - "version": "1.14.6", + "version": "1.14.7", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", diff --git a/package.json b/package.json index c345912b..7eab637d 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.14.6", + "version": "1.14.7", "repository": { "type": "git", "url": "git+https://github.com/tableau/tableau-mcp.git" From 330c7bf2ae194bf4fcba1f6b4de312420fc703d7 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Tue, 10 Feb 2026 16:53:05 -0800 Subject: [PATCH 16/18] Tidy up queryDatasource --- src/tools/queryDatasource/queryDatasource.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/queryDatasource/queryDatasource.ts b/src/tools/queryDatasource/queryDatasource.ts index 6617a280..fdff9326 100644 --- a/src/tools/queryDatasource/queryDatasource.ts +++ b/src/tools/queryDatasource/queryDatasource.ts @@ -56,13 +56,14 @@ export const getQueryDatasourceTool = ( server: Server, authInfo?: TableauAuthInfo, ): Tool => { + const config = getConfig(); const queryDatasourceTool = new Tool({ server, name: 'query-datasource', description: new Provider( async () => await getResultForTableauVersion({ - server: getConfig().server || authInfo?.server, + server: config.server || authInfo?.server, mappings: { '2025.3.0': queryDatasourceToolDescription20253, default: queryDatasourceToolDescription, @@ -80,7 +81,6 @@ export const getQueryDatasourceTool = ( { datasourceLuid, query, limit }, { requestId, sessionId, authInfo, signal }, ): Promise => { - const config = getConfig(); const restApiArgs = { config, requestId, From 25e1343b04622a80f6063ee4a8645e24e0a3986a Mon Sep 17 00:00:00 2001 From: Andy Young Date: Tue, 10 Feb 2026 16:54:59 -0800 Subject: [PATCH 17/18] Fix spelling --- ...nfig.test.ts => overridableConfig.test.ts} | 86 +++++++++---------- ...rideableConfig.ts => overridableConfig.ts} | 26 +++--- .../contentExploration/searchContentUtils.ts | 2 +- src/tools/listDatasources/listDatasources.ts | 2 +- src/tools/pulse/constrainPulseDefinitions.ts | 2 +- src/tools/pulse/constrainPulseMetrics.ts | 2 +- .../listPulseMetricSubscriptions.ts | 2 +- src/tools/resourceAccessChecker.ts | 2 +- src/tools/views/listViews.ts | 2 +- src/tools/workbooks/getWorkbook.ts | 2 +- src/tools/workbooks/listWorkbooks.ts | 2 +- src/utils/mcpSiteSettings.ts | 6 +- 12 files changed, 68 insertions(+), 68 deletions(-) rename src/{overrideableConfig.test.ts => overridableConfig.test.ts} (79%) rename src/{overrideableConfig.ts => overridableConfig.ts} (89%) diff --git a/src/overrideableConfig.test.ts b/src/overridableConfig.test.ts similarity index 79% rename from src/overrideableConfig.test.ts rename to src/overridableConfig.test.ts index 8be58b0f..4ef9ae83 100644 --- a/src/overrideableConfig.test.ts +++ b/src/overridableConfig.test.ts @@ -1,8 +1,8 @@ -import { exportedForTesting } from './overrideableConfig.js'; +import { exportedForTesting } from './overridableConfig.js'; import { stubDefaultEnvVars } from './testShared.js'; -describe('OverrideableConfig', () => { - const { OverrideableConfig } = exportedForTesting; +describe('OverridableConfig', () => { + const { OverridableConfig } = exportedForTesting; beforeEach(() => { vi.resetModules(); @@ -15,32 +15,32 @@ describe('OverrideableConfig', () => { }); it('should set disableQueryDatasourceValidationRequests to false by default', () => { - const config = new OverrideableConfig({}); + const config = new OverridableConfig({}); expect(config.disableQueryDatasourceValidationRequests).toBe(false); }); it('should set disableQueryDatasourceValidationRequests to true when specified', () => { vi.stubEnv('DISABLE_QUERY_DATASOURCE_VALIDATION_REQUESTS', 'true'); - const config = new OverrideableConfig({}); + const config = new OverridableConfig({}); expect(config.disableQueryDatasourceValidationRequests).toBe(true); }); it('should set disableMetadataApiRequests to false by default', () => { - const config = new OverrideableConfig({}); + const config = new OverridableConfig({}); expect(config.disableMetadataApiRequests).toBe(false); }); it('should set disableMetadataApiRequests to true when specified', () => { vi.stubEnv('DISABLE_METADATA_API_REQUESTS', 'true'); - const config = new OverrideableConfig({}); + const config = new OverridableConfig({}); expect(config.disableMetadataApiRequests).toBe(true); }); describe('Tool filtering', () => { it('should set empty arrays for includeTools and excludeTools when not specified', () => { - const config = new OverrideableConfig({}); + const config = new OverridableConfig({}); expect(config.includeTools).toEqual([]); expect(config.excludeTools).toEqual([]); }); @@ -48,42 +48,42 @@ describe('OverrideableConfig', () => { it('should parse INCLUDE_TOOLS into an array of valid tool names', () => { vi.stubEnv('INCLUDE_TOOLS', 'query-datasource,get-datasource-metadata'); - const config = new OverrideableConfig({}); + const config = new OverridableConfig({}); expect(config.includeTools).toEqual(['query-datasource', 'get-datasource-metadata']); }); it('should parse INCLUDE_TOOLS into an array of valid tool names when tool group names are used', () => { vi.stubEnv('INCLUDE_TOOLS', 'query-datasource,workbook'); - const config = new OverrideableConfig({}); + const config = new OverridableConfig({}); expect(config.includeTools).toEqual(['query-datasource', 'list-workbooks', 'get-workbook']); }); it('should parse EXCLUDE_TOOLS into an array of valid tool names', () => { vi.stubEnv('EXCLUDE_TOOLS', 'query-datasource'); - const config = new OverrideableConfig({}); + const config = new OverridableConfig({}); expect(config.excludeTools).toEqual(['query-datasource']); }); it('should parse EXCLUDE_TOOLS into an array of valid tool names when tool group names are used', () => { vi.stubEnv('EXCLUDE_TOOLS', 'query-datasource,workbook'); - const config = new OverrideableConfig({}); + const config = new OverridableConfig({}); expect(config.excludeTools).toEqual(['query-datasource', 'list-workbooks', 'get-workbook']); }); it('should filter out invalid tool names from INCLUDE_TOOLS', () => { vi.stubEnv('INCLUDE_TOOLS', 'query-datasource,order-hamburgers'); - const config = new OverrideableConfig({}); + const config = new OverridableConfig({}); expect(config.includeTools).toEqual(['query-datasource']); }); it('should filter out invalid tool names from EXCLUDE_TOOLS', () => { vi.stubEnv('EXCLUDE_TOOLS', 'query-datasource,order-hamburgers'); - const config = new OverrideableConfig({}); + const config = new OverridableConfig({}); expect(config.excludeTools).toEqual(['query-datasource']); }); @@ -91,7 +91,7 @@ describe('OverrideableConfig', () => { vi.stubEnv('INCLUDE_TOOLS', 'query-datasource'); vi.stubEnv('EXCLUDE_TOOLS', 'get-datasource-metadata'); - expect(() => new OverrideableConfig({})).toThrow( + expect(() => new OverridableConfig({})).toThrow( 'Cannot include and exclude tools simultaneously', ); }); @@ -99,7 +99,7 @@ describe('OverrideableConfig', () => { it('should throw error when both INCLUDE_TOOLS and EXCLUDE_TOOLS are specified with tool group names', () => { vi.stubEnv('INCLUDE_TOOLS', 'datasource'); vi.stubEnv('EXCLUDE_TOOLS', 'workbook'); - expect(() => new OverrideableConfig({})).toThrow( + expect(() => new OverridableConfig({})).toThrow( 'Cannot include and exclude tools simultaneously', ); }); @@ -107,7 +107,7 @@ describe('OverrideableConfig', () => { describe('Bounded context parsing', () => { it('should set boundedContext to null sets when no project, datasource, or workbook IDs are provided', () => { - const config = new OverrideableConfig({}); + const config = new OverridableConfig({}); expect(config.boundedContext).toEqual({ projectIds: null, datasourceIds: null, @@ -122,7 +122,7 @@ describe('OverrideableConfig', () => { vi.stubEnv('INCLUDE_WORKBOOK_IDS', '112,113'); vi.stubEnv('INCLUDE_TAGS', 'tag1,tag2'); - const config = new OverrideableConfig({}); + const config = new OverridableConfig({}); expect(config.boundedContext).toEqual({ projectIds: new Set(['123', '456']), datasourceIds: new Set(['789', '101']), @@ -134,7 +134,7 @@ describe('OverrideableConfig', () => { it('should throw error when INCLUDE_PROJECT_IDS is set to an empty string', () => { vi.stubEnv('INCLUDE_PROJECT_IDS', ''); - expect(() => new OverrideableConfig({})).toThrow( + expect(() => new OverridableConfig({})).toThrow( 'When set, the environment variable INCLUDE_PROJECT_IDS must have at least one value', ); }); @@ -142,7 +142,7 @@ describe('OverrideableConfig', () => { it('should throw error when INCLUDE_DATASOURCE_IDS is set to an empty string', () => { vi.stubEnv('INCLUDE_DATASOURCE_IDS', ''); - expect(() => new OverrideableConfig({})).toThrow( + expect(() => new OverridableConfig({})).toThrow( 'When set, the environment variable INCLUDE_DATASOURCE_IDS must have at least one value', ); }); @@ -150,7 +150,7 @@ describe('OverrideableConfig', () => { it('should throw error when INCLUDE_WORKBOOK_IDS is set to an empty string', () => { vi.stubEnv('INCLUDE_WORKBOOK_IDS', ''); - expect(() => new OverrideableConfig({})).toThrow( + expect(() => new OverridableConfig({})).toThrow( 'When set, the environment variable INCLUDE_WORKBOOK_IDS must have at least one value', ); }); @@ -158,7 +158,7 @@ describe('OverrideableConfig', () => { it('should throw error when INCLUDE_TAGS is set to an empty string', () => { vi.stubEnv('INCLUDE_TAGS', ''); - expect(() => new OverrideableConfig({})).toThrow( + expect(() => new OverridableConfig({})).toThrow( 'When set, the environment variable INCLUDE_TAGS must have at least one value', ); }); @@ -166,61 +166,61 @@ describe('OverrideableConfig', () => { describe('Max results limit parsing', () => { it('should return null when MAX_RESULT_LIMIT and MAX_RESULT_LIMITS are not set', () => { - expect(new OverrideableConfig({}).getMaxResultLimit('query-datasource')).toBeNull(); + expect(new OverridableConfig({}).getMaxResultLimit('query-datasource')).toBeNull(); }); it('should return the max result limit when MAX_RESULT_LIMITS has a single tool', () => { vi.stubEnv('MAX_RESULT_LIMITS', 'query-datasource:100'); - expect(new OverrideableConfig({}).getMaxResultLimit('query-datasource')).toEqual(100); + expect(new OverridableConfig({}).getMaxResultLimit('query-datasource')).toEqual(100); }); it('should return the max result limit when MAX_RESULT_LIMITS has a single tool group', () => { vi.stubEnv('MAX_RESULT_LIMITS', 'datasource:200'); - expect(new OverrideableConfig({}).getMaxResultLimit('query-datasource')).toEqual(200); + expect(new OverridableConfig({}).getMaxResultLimit('query-datasource')).toEqual(200); }); it('should return the max result limit for the tool when a tool and a tool group are both specified', () => { vi.stubEnv('MAX_RESULT_LIMITS', 'query-datasource:100,datasource:200'); - expect(new OverrideableConfig({}).getMaxResultLimit('query-datasource')).toEqual(100); - expect(new OverrideableConfig({}).getMaxResultLimit('list-datasources')).toEqual(200); + expect(new OverridableConfig({}).getMaxResultLimit('query-datasource')).toEqual(100); + expect(new OverridableConfig({}).getMaxResultLimit('list-datasources')).toEqual(200); }); it('should fallback to MAX_RESULT_LIMIT when a tool-specific max result limit is not set', () => { vi.stubEnv('MAX_RESULT_LIMITS', 'query-datasource:100'); vi.stubEnv('MAX_RESULT_LIMIT', '300'); - expect(new OverrideableConfig({}).getMaxResultLimit('query-datasource')).toEqual(100); - expect(new OverrideableConfig({}).getMaxResultLimit('list-datasources')).toEqual(300); + expect(new OverridableConfig({}).getMaxResultLimit('query-datasource')).toEqual(100); + expect(new OverridableConfig({}).getMaxResultLimit('list-datasources')).toEqual(300); }); it('should return null when MAX_RESULT_LIMITS has a non-number', () => { vi.stubEnv('MAX_RESULT_LIMITS', 'query-datasource:abc'); - const config = new OverrideableConfig({}); + const config = new OverridableConfig({}); expect(config.getMaxResultLimit('query-datasource')).toBe(null); }); it('should return null when MAX_RESULT_LIMIT is specified as a non-number', () => { vi.stubEnv('MAX_RESULT_LIMIT', 'abc'); - const config = new OverrideableConfig({}); + const config = new OverridableConfig({}); expect(config.getMaxResultLimit('query-datasource')).toBe(null); }); it('should return null when MAX_RESULT_LIMITS has a negative number', () => { vi.stubEnv('MAX_RESULT_LIMITS', 'query-datasource:-100'); - const config = new OverrideableConfig({}); + const config = new OverridableConfig({}); expect(config.getMaxResultLimit('query-datasource')).toBe(null); }); it('should return null when MAX_RESULT_LIMIT is specified as a negative number', () => { vi.stubEnv('MAX_RESULT_LIMIT', '-100'); - const config = new OverrideableConfig({}); + const config = new OverridableConfig({}); expect(config.getMaxResultLimit('query-datasource')).toBe(null); }); }); @@ -229,7 +229,7 @@ describe('OverrideableConfig', () => { it('should override INCLUDE_TOOLS', () => { vi.stubEnv('INCLUDE_TOOLS', 'list-views'); - const config = new OverrideableConfig({ + const config = new OverridableConfig({ INCLUDE_TOOLS: 'query-datasource', }); @@ -239,7 +239,7 @@ describe('OverrideableConfig', () => { it('should override EXCLUDE_TOOLS', () => { vi.stubEnv('EXCLUDE_TOOLS', 'list-views'); - const config = new OverrideableConfig({ + const config = new OverridableConfig({ EXCLUDE_TOOLS: 'get-datasource-metadata', }); @@ -249,7 +249,7 @@ describe('OverrideableConfig', () => { it('should override INCLUDE_PROJECT_IDS', () => { vi.stubEnv('INCLUDE_PROJECT_IDS', '999'); - const config = new OverrideableConfig({ + const config = new OverridableConfig({ INCLUDE_PROJECT_IDS: '123,456', }); @@ -259,7 +259,7 @@ describe('OverrideableConfig', () => { it('should override INCLUDE_DATASOURCE_IDS', () => { vi.stubEnv('INCLUDE_DATASOURCE_IDS', '999'); - const config = new OverrideableConfig({ + const config = new OverridableConfig({ INCLUDE_DATASOURCE_IDS: '123,456', }); @@ -269,7 +269,7 @@ describe('OverrideableConfig', () => { it('should override INCLUDE_WORKBOOK_IDS', () => { vi.stubEnv('INCLUDE_WORKBOOK_IDS', '999'); - const config = new OverrideableConfig({ + const config = new OverridableConfig({ INCLUDE_WORKBOOK_IDS: '123,456', }); @@ -279,7 +279,7 @@ describe('OverrideableConfig', () => { it('should override INCLUDE_TAGS', () => { vi.stubEnv('INCLUDE_TAGS', '999'); - const config = new OverrideableConfig({ + const config = new OverridableConfig({ INCLUDE_TAGS: '123,456', }); @@ -289,7 +289,7 @@ describe('OverrideableConfig', () => { it('should override MAX_RESULT_LIMIT', () => { vi.stubEnv('MAX_RESULT_LIMIT', '10'); - const config = new OverrideableConfig({ + const config = new OverridableConfig({ MAX_RESULT_LIMIT: '99', }); @@ -300,7 +300,7 @@ describe('OverrideableConfig', () => { vi.stubEnv('MAX_RESULT_LIMIT', '10'); vi.stubEnv('MAX_RESULT_LIMITS', 'query-datasource:100'); - const config = new OverrideableConfig({ + const config = new OverridableConfig({ MAX_RESULT_LIMIT: '99', MAX_RESULT_LIMITS: 'query-datasource:999', }); @@ -312,7 +312,7 @@ describe('OverrideableConfig', () => { it('should override DISABLE_QUERY_DATASOURCE_VALIDATION_REQUESTS', () => { vi.stubEnv('DISABLE_QUERY_DATASOURCE_VALIDATION_REQUESTS', 'false'); - const config = new OverrideableConfig({ + const config = new OverridableConfig({ DISABLE_QUERY_DATASOURCE_VALIDATION_REQUESTS: 'true', }); @@ -322,7 +322,7 @@ describe('OverrideableConfig', () => { it('should override DISABLE_METADATA_API_REQUESTS', () => { vi.stubEnv('DISABLE_METADATA_API_REQUESTS', 'false'); - const config = new OverrideableConfig({ + const config = new OverridableConfig({ DISABLE_METADATA_API_REQUESTS: 'true', }); diff --git a/src/overrideableConfig.ts b/src/overridableConfig.ts similarity index 89% rename from src/overrideableConfig.ts rename to src/overridableConfig.ts index 1488974f..e1a9333d 100644 --- a/src/overrideableConfig.ts +++ b/src/overridableConfig.ts @@ -2,7 +2,7 @@ import { ProcessEnvEx } from '../types/process-env.js'; import { removeClaudeMcpBundleUserConfigTemplates } from './config.js'; import { isToolGroupName, isToolName, toolGroups, ToolName } from './tools/toolName.js'; -const overrideableVariables = [ +const overridableVariables = [ 'INCLUDE_TOOLS', 'EXCLUDE_TOOLS', 'INCLUDE_PROJECT_IDS', @@ -15,17 +15,17 @@ const overrideableVariables = [ 'DISABLE_METADATA_API_REQUESTS', ] as const satisfies ReadonlyArray; -type OverrideableVariable = (typeof overrideableVariables)[number]; -function isOverrideableVariable(variable: unknown): variable is OverrideableVariable { - return overrideableVariables.some((v) => v === variable); +type OverridableVariable = (typeof overridableVariables)[number]; +function isOverridableVariable(variable: unknown): variable is OverridableVariable { + return overridableVariables.some((v) => v === variable); } -function filterEnvVarsToOverrideable( +function filterEnvVarsToOverridable( environmentVariables: Record, -): Record { +): Record { return Object.fromEntries( - Object.entries(environmentVariables).filter(([key]) => isOverrideableVariable(key)), - ) as Record; + Object.entries(environmentVariables).filter(([key]) => isOverridableVariable(key)), + ) as Record; } export type BoundedContext = { @@ -35,7 +35,7 @@ export type BoundedContext = { tags: Set | null; }; -export class OverrideableConfig { +export class OverridableConfig { private maxResultLimit: number | null; private maxResultLimits: Map | null; @@ -54,7 +54,7 @@ export class OverrideableConfig { constructor(overrides: Record | undefined) { const cleansedVars = removeClaudeMcpBundleUserConfigTemplates({ ...process.env, - ...(overrides ? filterEnvVarsToOverrideable(overrides) : {}), + ...(overrides ? filterEnvVarsToOverridable(overrides) : {}), }); const { @@ -173,10 +173,10 @@ function getMaxResultLimits(maxResultLimits: string): Map | undefined, -): OverrideableConfig => new OverrideableConfig(overrides); +): OverridableConfig => new OverridableConfig(overrides); export const exportedForTesting = { - OverrideableConfig, + OverridableConfig: OverridableConfig, }; diff --git a/src/tools/contentExploration/searchContentUtils.ts b/src/tools/contentExploration/searchContentUtils.ts index a22f3f49..04bcbae9 100644 --- a/src/tools/contentExploration/searchContentUtils.ts +++ b/src/tools/contentExploration/searchContentUtils.ts @@ -1,4 +1,4 @@ -import { BoundedContext } from '../../overrideableConfig.js'; +import { BoundedContext } from '../../overridableConfig.js'; import { OrderBy, SearchContentFilter, diff --git a/src/tools/listDatasources/listDatasources.ts b/src/tools/listDatasources/listDatasources.ts index 3228e0c5..f64a1f44 100644 --- a/src/tools/listDatasources/listDatasources.ts +++ b/src/tools/listDatasources/listDatasources.ts @@ -3,7 +3,7 @@ import { Ok } from 'ts-results-es'; import { z } from 'zod'; import { getConfig } from '../../config.js'; -import { BoundedContext } from '../../overrideableConfig.js'; +import { BoundedContext } from '../../overridableConfig.js'; import { useRestApi } from '../../restApiInstance.js'; import { DataSource } from '../../sdks/tableau/types/dataSource.js'; import { Server } from '../../server.js'; diff --git a/src/tools/pulse/constrainPulseDefinitions.ts b/src/tools/pulse/constrainPulseDefinitions.ts index 2b814480..aa03dd21 100644 --- a/src/tools/pulse/constrainPulseDefinitions.ts +++ b/src/tools/pulse/constrainPulseDefinitions.ts @@ -1,4 +1,4 @@ -import { BoundedContext } from '../../overrideableConfig.js'; +import { BoundedContext } from '../../overridableConfig.js'; import { PulseMetricDefinition } from '../../sdks/tableau/types/pulse.js'; import { ConstrainedResult } from '../tool.js'; diff --git a/src/tools/pulse/constrainPulseMetrics.ts b/src/tools/pulse/constrainPulseMetrics.ts index 0c89bf99..50c0632d 100644 --- a/src/tools/pulse/constrainPulseMetrics.ts +++ b/src/tools/pulse/constrainPulseMetrics.ts @@ -1,4 +1,4 @@ -import { BoundedContext } from '../../overrideableConfig.js'; +import { BoundedContext } from '../../overridableConfig.js'; import { PulseMetric } from '../../sdks/tableau/types/pulse.js'; import { ConstrainedResult } from '../tool.js'; diff --git a/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts b/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts index 33a94621..1ec8c1ed 100644 --- a/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts +++ b/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts @@ -1,7 +1,7 @@ import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { getConfig } from '../../../config.js'; -import { BoundedContext } from '../../../overrideableConfig.js'; +import { BoundedContext } from '../../../overridableConfig.js'; import { RestApiArgs, useRestApi } from '../../../restApiInstance.js'; import { PulseMetricSubscription } from '../../../sdks/tableau/types/pulse.js'; import { Server } from '../../../server.js'; diff --git a/src/tools/resourceAccessChecker.ts b/src/tools/resourceAccessChecker.ts index b46a09e9..7407a6c9 100644 --- a/src/tools/resourceAccessChecker.ts +++ b/src/tools/resourceAccessChecker.ts @@ -1,4 +1,4 @@ -import { BoundedContext } from '../overrideableConfig.js'; +import { BoundedContext } from '../overridableConfig.js'; import { RestApiArgs, useRestApi } from '../restApiInstance.js'; import { DataSource } from '../sdks/tableau/types/dataSource.js'; import { View } from '../sdks/tableau/types/view.js'; diff --git a/src/tools/views/listViews.ts b/src/tools/views/listViews.ts index 7eeaf44e..a0d05c95 100644 --- a/src/tools/views/listViews.ts +++ b/src/tools/views/listViews.ts @@ -3,7 +3,7 @@ import { Ok } from 'ts-results-es'; import { z } from 'zod'; import { getConfig } from '../../config.js'; -import { BoundedContext } from '../../overrideableConfig.js'; +import { BoundedContext } from '../../overridableConfig.js'; import { useRestApi } from '../../restApiInstance.js'; import { View } from '../../sdks/tableau/types/view.js'; import { Server } from '../../server.js'; diff --git a/src/tools/workbooks/getWorkbook.ts b/src/tools/workbooks/getWorkbook.ts index 092d2e93..5b39caeb 100644 --- a/src/tools/workbooks/getWorkbook.ts +++ b/src/tools/workbooks/getWorkbook.ts @@ -3,7 +3,7 @@ import { Err, Ok } from 'ts-results-es'; import { z } from 'zod'; import { getConfig } from '../../config.js'; -import { BoundedContext } from '../../overrideableConfig.js'; +import { BoundedContext } from '../../overridableConfig.js'; import { useRestApi } from '../../restApiInstance.js'; import { Workbook } from '../../sdks/tableau/types/workbook.js'; import { Server } from '../../server.js'; diff --git a/src/tools/workbooks/listWorkbooks.ts b/src/tools/workbooks/listWorkbooks.ts index 045893d8..16b25743 100644 --- a/src/tools/workbooks/listWorkbooks.ts +++ b/src/tools/workbooks/listWorkbooks.ts @@ -3,7 +3,7 @@ import { Ok } from 'ts-results-es'; import { z } from 'zod'; import { getConfig } from '../../config.js'; -import { BoundedContext } from '../../overrideableConfig.js'; +import { BoundedContext } from '../../overridableConfig.js'; import { useRestApi } from '../../restApiInstance.js'; import { Workbook } from '../../sdks/tableau/types/workbook.js'; import { Server } from '../../server.js'; diff --git a/src/utils/mcpSiteSettings.ts b/src/utils/mcpSiteSettings.ts index 9c4e56c7..08f85898 100644 --- a/src/utils/mcpSiteSettings.ts +++ b/src/utils/mcpSiteSettings.ts @@ -1,5 +1,5 @@ import { Config, getConfig } from '../config.js'; -import { getOverrideableConfig, OverrideableConfig } from '../overrideableConfig.js'; +import { getOverridableConfig, OverridableConfig } from '../overridableConfig.js'; import { RestApiArgs, useRestApi } from '../restApiInstance.js'; import { McpSiteSettings } from '../sdks/tableau/types/mcpSiteSettings.js'; import { ExpiringMap } from './expiringMap.js'; @@ -53,7 +53,7 @@ export async function getConfigWithOverrides({ restApiArgs, }: { restApiArgs: GetConfigWithOverridesArgs; -}): Promise { +}): Promise { const config = restApiArgs.config ?? getConfig(); const signal = restApiArgs.signal ?? AbortSignal.timeout(config.maxRequestTimeoutMs); @@ -61,5 +61,5 @@ export async function getConfigWithOverrides({ restApiArgs: { ...restApiArgs, config, signal }, }); - return getOverrideableConfig(overrides); + return getOverridableConfig(overrides); } From 66f12260be6312a81f9e2d328cbc5b849f2e6bc0 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Tue, 10 Feb 2026 23:17:51 -0800 Subject: [PATCH 18/18] Copilot --- src/tools/contentExploration/searchContent.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tools/contentExploration/searchContent.ts b/src/tools/contentExploration/searchContent.ts index 5f226eaf..5a2e9a70 100644 --- a/src/tools/contentExploration/searchContent.ts +++ b/src/tools/contentExploration/searchContent.ts @@ -91,7 +91,6 @@ This tool searches across all supported content types for objects relevant to th return new Ok( await useRestApi({ ...restApiArgs, - config, jwtScopes: ['tableau:content:read'], callback: async (restApi) => { const maxResultLimit = configWithOverrides.getMaxResultLimit(