diff --git a/.gitignore b/.gitignore index 6ffc2371..ac491756 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ coverage/ config.json env.list manifest.json -tableau-mcp.dxt \ No newline at end of file +tableau-mcp.dxt +*.pem \ No newline at end of file diff --git a/README.md b/README.md index b03f0b36..e2e70a49 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ These config files will be used in tool configuration explained below. | **Variable** | **Description** | **Default** | **Note** | | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `TRANSPORT` | The MCP transport type to use for the server. | `stdio` | Possible values are `stdio` or `http`. For `http`, see [HTTP Server Configuration](#http-server-configuration) below for additional variables. See [Transports][mcp-transport] for details. | -| `AUTH` | The authentication method to use by the server. | `pat` | Possible values are `pat` or `direct-trust`. See below sections for additional required variables depending on the desired method. | +| `AUTH` | The authentication method to use by the server. | `pat` | Possible values are `pat`, `direct-trust`, or `jwt-provider`. See below sections for additional required variables depending on the desired method. | | `DEFAULT_LOG_LEVEL` | The default logging level of the server. | `debug` | | | `DATASOURCE_CREDENTIALS` | A JSON string that includes usernames and passwords for any datasources that require them. | Empty string | Format is provided in the [DATASOURCE_CREDENTIALS](#datasource_credentials) section below. | | `DISABLE_LOG_MASKING` | Disable masking of credentials in logs. For debug purposes only. | `false` | | @@ -255,6 +255,91 @@ additional user attributes to include on the JWT. The following is an example: { "region": "West" } ``` +#### JWT Provider Configuration + +When `AUTH` is `jwt-provider`, before the MCP server authenticates to the Tableau REST API, it will +make a POST request to the endpoint provided in `JWT_PROVIDER_URL`. This endpoint must return the +JSON web token to then be used to authenticate to the REST API. It must only accept and return JSON. + +The following environment variables are required: + +| **Variable** | **Description** | **Notes** | +| ------------------------------ | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `JWT_PROVIDER_URL` | The URL of the JWT provider endpoint. | Example: `https://example.com/jwt-provider` | +| `JWT_PROVIDER_SECRET` | The secret value to encrypt and provide on the secret header. | The JWT provider must decrypt the value of the secret header using the private key and verify it matches. See below for an example. | +| `JWT_PROVIDER_PUBLIC_KEY_PATH` | The absolute path to the RSA public key (.pem) file used to encrypt the JWT provider secret header. | Only PEM format is supported. If you need a key pair, you can generate them using [openssl-genrsa][genrsa] e.g. `openssl genrsa -out private.pem` and `openssl rsa -in private.pem -pubout -out public.pem` | +| `JWT_SUB_CLAIM` | The username provided as the `username` in the request body. | The JWT provider doesn't necessarilly need to use this, but had `AUTH` been `direct-trust`, it would have been used for the `sub` claim of the JWT generated by the Tableau MCP server. | + +POST request header: + +``` +x-tabmcp-jwt-provider-secret: [Encrypted value of JWT_PROVIDER_SECRET] +``` + +POST request body: + +```js +{ + username: "user@tableau.com", // The value of JWT_SUB_CLAIM + scopes: ["tableau:example:scope"], // The list of scopes the JWT should have + source: "tableau-mcp", + resource: 'mcp-tool-name', // The name of the tool being called e.g. query-datasource + server: "https://tableau.example.com", // The value of SERVER + siteName: "siteName", // The value of SITE_NAME +} +``` + +Expected response: + +```json +{ + "jwt": "eyJhbGciOiJI..." +} +``` + +Example [Express][express] route handler: + +```js +async function jwtProviderRouteHandler(req, res) { + // Read the secret header from the request + const secret = req.headers['x-tabmcp-jwt-provider-secret']; + + // Decrypt the secret using the private key. + // The secret was encrypted by the Tableau MCP server using the public key. + const privateKey = crypto.createPrivateKey({ + format: 'pem', + key: readFileSync(process.env.PRIVATE_KEY_PATH), + passphrase: process.env.PRIVATE_KEY_PASSPHRASE, + }); + + // compactDecrypt is a function from the jose package + // https://www.npmjs.com/package/jose + const { plaintext } = await compactDecrypt(secret, privateKey); + const equal = crypto.timingSafeEqual( + plaintext, + new TextEncoder().encode(process.env.JWT_PROVIDER_SECRET), + ); + + if (!equal) { + res.status(401).json({ + error: 'Unauthorized', + }); + return; + } + + // Read the values provided by the Tableau MCP server + const { username, scopes, source, resource, server, siteName } = req.body; + + // Add isAdmin user attribute for admin user + const userAttributes = { isAdmin: username === 'admin@example.com' }; + + // An example generateJwt function can be found here: + // https://github.com/tableau/connected-apps-jwt-samples/blob/main/javascript/index.js + const jwt = generateJwt(userAttributes); + res.json({ jwt }); +} +``` + ##### DATASOURCE_CREDENTIALS The `DATASOURCE_CREDENTIALS` environment variable is a JSON string that includes usernames and diff --git a/src/config.test.ts b/src/config.test.ts index 89c61a08..9a9514c0 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -33,6 +33,9 @@ describe('Config', () => { CONNECTED_APP_SECRET_ID: undefined, CONNECTED_APP_SECRET_VALUE: undefined, JWT_ADDITIONAL_PAYLOAD: undefined, + JWT_PROVIDER_URL: undefined, + JWT_PROVIDER_SECRET: undefined, + JWT_PROVIDER_SECRET_PUBLIC_KEY_PATH: undefined, DATASOURCE_CREDENTIALS: undefined, DEFAULT_LOG_LEVEL: undefined, DISABLE_LOG_MASKING: undefined, @@ -625,4 +628,46 @@ describe('Config', () => { expect(config.jwtAdditionalPayload).toBe('{}'); }); }); + + describe('JWT auth configuration', () => { + it('should parse jwt auth configuration when all required variables are provided', () => { + process.env = { + ...process.env, + ...defaultEnvVars, + AUTH: 'jwt-provider', + JWT_PROVIDER_URL: 'https://example.com/jwt', + JWT_PROVIDER_SECRET: 'secret', + JWT_PROVIDER_SECRET_PUBLIC_KEY_PATH: 'public.pem', + JWT_SUB_CLAIM: 'user@example.com', + }; + + const config = new Config(); + expect(config.auth).toBe('jwt-provider'); + expect(config.jwtProviderUrl).toBe('https://example.com/jwt'); + expect(config.jwtSubClaim).toBe('user@example.com'); + }); + + it('should throw error when JWT_PROVIDER_URL is missing for jwt auth', () => { + process.env = { + ...process.env, + ...defaultEnvVars, + AUTH: 'jwt-provider', + JWT_PROVIDER_URL: undefined, + }; + + expect(() => new Config()).toThrow('The environment variable JWT_PROVIDER_URL is not set'); + }); + + it('should throw error when JWT_SUB_CLAIM is missing for jwt auth', () => { + process.env = { + ...process.env, + ...defaultEnvVars, + AUTH: 'jwt-provider', + JWT_PROVIDER_URL: 'https://example.com', + JWT_SUB_CLAIM: undefined, + }; + + expect(() => new Config()).toThrow('The environment variable JWT_SUB_CLAIM is not set'); + }); + }); }); diff --git a/src/config.ts b/src/config.ts index fcff3a0d..007511bb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,13 +1,21 @@ import { CorsOptions } from 'cors'; +import { createPublicKey } from 'crypto'; +import { existsSync, readFileSync } from 'fs'; +import { CompactEncrypt } from 'jose'; import { isToolGroupName, isToolName, toolGroups, ToolName } from './tools/toolName.js'; import { isTransport, TransportName } from './transports.js'; import invariant from './utils/invariant.js'; -const authTypes = ['pat', 'direct-trust'] as const; +const authTypes = ['pat', 'direct-trust', 'jwt-provider'] as const; type AuthType = (typeof authTypes)[number]; export class Config { + private readonly _jwtProviderSecretPublicKeyPath: string; + private readonly _jwtProviderSecret: string; + + private _jwtProviderEncryptedSecret: string | undefined; + auth: AuthType; server: string; transport: TransportName; @@ -23,6 +31,7 @@ export class Config { connectedAppSecretId: string; connectedAppSecretValue: string; jwtAdditionalPayload: string; + jwtProviderUrl: string; datasourceCredentials: string; defaultLogLevel: string; disableLogMasking: boolean; @@ -49,6 +58,9 @@ export class Config { CONNECTED_APP_SECRET_ID: secretId, CONNECTED_APP_SECRET_VALUE: secretValue, JWT_ADDITIONAL_PAYLOAD: jwtAdditionalPayload, + JWT_PROVIDER_URL: jwtProviderUrl, + JWT_PROVIDER_SECRET: jwtProviderSecret, + JWT_PROVIDER_SECRET_PUBLIC_KEY_PATH: jwtProviderSecretPublicKeyPath, DATASOURCE_CREDENTIALS: datasourceCredentials, DEFAULT_LOG_LEVEL: defaultLogLevel, DISABLE_LOG_MASKING: disableLogMasking, @@ -107,6 +119,18 @@ export class Config { invariant(clientId, 'The environment variable CONNECTED_APP_CLIENT_ID is not set'); invariant(secretId, 'The environment variable CONNECTED_APP_SECRET_ID is not set'); invariant(secretValue, 'The environment variable CONNECTED_APP_SECRET_VALUE is not set'); + } else if (this.auth === 'jwt-provider') { + invariant(jwtProviderUrl, 'The environment variable JWT_PROVIDER_URL is not set'); + invariant(jwtSubClaim, 'The environment variable JWT_SUB_CLAIM is not set'); + invariant(jwtProviderSecret, 'The environment variable JWT_PROVIDER_SECRET is not set'); + invariant( + jwtProviderSecretPublicKeyPath, + 'The environment variable JWT_PROVIDER_SECRET_PUBLIC_KEY_PATH is not set', + ); + + if (process.env.TABLEAU_MCP_TEST !== 'true' && !existsSync(jwtProviderSecretPublicKeyPath)) { + throw new Error(`The file ${jwtProviderSecretPublicKeyPath} does not exist`); + } } this.server = server; @@ -117,7 +141,28 @@ export class Config { this.connectedAppSecretId = secretId ?? ''; this.connectedAppSecretValue = secretValue ?? ''; this.jwtAdditionalPayload = jwtAdditionalPayload || '{}'; + this.jwtProviderUrl = jwtProviderUrl ?? ''; + this._jwtProviderSecretPublicKeyPath = jwtProviderSecretPublicKeyPath ?? ''; + this._jwtProviderSecret = jwtProviderSecret ?? ''; } + + getJwtProviderEncryptedSecret = async (): Promise => { + if (!this._jwtProviderEncryptedSecret) { + const publicKeyContents = readFileSync(this._jwtProviderSecretPublicKeyPath); + const publicKey = createPublicKey({ + key: publicKeyContents, + format: 'pem', + }); + + this._jwtProviderEncryptedSecret = await new CompactEncrypt( + new TextEncoder().encode(this._jwtProviderSecret), + ) + .setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM' }) + .encrypt(publicKey); + } + + return this._jwtProviderEncryptedSecret; + }; } function validateServer(server: string): void { diff --git a/src/restApiInstance.test.ts b/src/restApiInstance.test.ts index 596ef6e7..c428d643 100644 --- a/src/restApiInstance.test.ts +++ b/src/restApiInstance.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { getConfig } from './config.js'; import { log } from './logging/log.js'; @@ -50,6 +50,7 @@ describe('restApiInstance', () => { requestId: mockRequestId, server: new Server(), jwtScopes: [], + context: 'none', callback: (restApi) => Promise.resolve(restApi), }); @@ -233,4 +234,98 @@ describe('restApiInstance', () => { ); }); }); + + describe('JWT auth', () => { + const fetchJsonResolve = vi.fn(); + + const mockJwtProviderResponses = vi.hoisted(() => ({ + success: { + jwt: 'mock-jwt', + }, + error: { + token: 'mock-jwt', + }, + })); + + const mocks = vi.hoisted(() => ({ + mockJwtProviderResponse: vi.fn(), + })); + + beforeEach(() => { + vi.spyOn(global, 'fetch').mockImplementation( + vi.fn(async () => + Promise.resolve({ + ok: true, + json: async () => { + const json = await mocks.mockJwtProviderResponse(); + fetchJsonResolve(json); + return Promise.resolve(json); + }, + }), + ) as Mock, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should create a new RestApi instance and sign in', async () => { + mocks.mockJwtProviderResponse.mockResolvedValue(mockJwtProviderResponses.success); + + const config = getConfig(); + config.auth = 'jwt-provider'; + config.jwtProviderUrl = 'https://example.com/jwt'; + config.jwtSubClaim = 'user@example.com'; + config.getJwtProviderEncryptedSecret = vi.fn().mockResolvedValue('mock-encrypted-secret'); + + await useRestApi({ + config, + requestId: mockRequestId, + server: new Server(), + jwtScopes: ['tableau:content:read'], + context: 'query-datasource', + callback: (restApi) => Promise.resolve(restApi), + }); + + expect(fetch).toHaveBeenCalledWith(config.jwtProviderUrl, { + method: 'POST', + body: JSON.stringify({ + username: config.jwtSubClaim, + scopes: ['tableau:content:read'], + source: 'test-server', + resource: 'query-datasource', + server: 'https://my-tableau-server.com', + siteName: 'tc25', + }), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'x-tabmcp-jwt-provider-secret': 'mock-encrypted-secret', + }, + }); + expect(fetchJsonResolve).toHaveBeenCalledWith(mockJwtProviderResponses.success); + }); + + it('should throw an error if the JWT provider returns an invalid response', async () => { + mocks.mockJwtProviderResponse.mockResolvedValue(mockJwtProviderResponses.error); + + const config = getConfig(); + config.auth = 'jwt-provider'; + config.jwtProviderUrl = 'https://example.com/jwt'; + config.jwtSubClaim = 'user@example.com'; + config.getJwtProviderEncryptedSecret = vi.fn().mockResolvedValue('mock-encrypted-secret'); + + await expect( + useRestApi({ + config, + requestId: mockRequestId, + server: new Server(), + jwtScopes: ['tableau:content:read'], + context: 'query-datasource', + callback: (restApi) => Promise.resolve(restApi), + }), + ).rejects.toThrow('Invalid JWT response, expected: { "jwt": "..." }'); + }); + }); }); diff --git a/src/restApiInstance.ts b/src/restApiInstance.ts index f622b052..d5bf83db 100644 --- a/src/restApiInstance.ts +++ b/src/restApiInstance.ts @@ -16,7 +16,9 @@ import { } from './sdks/tableau/interceptors.js'; import RestApi from './sdks/tableau/restApi.js'; import { Server } from './server.js'; +import { ToolName } from './tools/toolName.js'; import { getExceptionMessage } from './utils/getExceptionMessage.js'; +import { getJwtFromProvider } from './utils/getJwtFromProvider.js'; type JwtScopes = | 'tableau:viz_data_service:read' @@ -27,12 +29,19 @@ type JwtScopes = | 'tableau:insights:read' | 'tableau:views:download'; -const getNewRestApiInstanceAsync = async ( - config: Config, - requestId: RequestId, - server: Server, - jwtScopes: Set, -): Promise => { +const getNewRestApiInstanceAsync = async ({ + config, + requestId, + server, + jwtScopes, + context, +}: { + config: Config; + requestId: RequestId; + server: Server; + jwtScopes: Set; + context: ToolName | 'none'; +}): Promise => { const restApi = new RestApi(config.server, { requestInterceptor: [ getRequestInterceptor(server, requestId), @@ -62,6 +71,25 @@ const getNewRestApiInstanceAsync = async ( scopes: jwtScopes, additionalPayload: getJwtAdditionalPayload(config), }); + } else if (config.auth === 'jwt-provider') { + const jwt = await getJwtFromProvider({ + jwtProviderUrl: config.jwtProviderUrl, + jwtProviderEncryptedSecret: await config.getJwtProviderEncryptedSecret(), + body: { + username: getJwtSubClaim(config), + scopes: [...jwtScopes], + source: server.name, + resource: context, + server: config.server, + siteName: config.siteName, + }, + }); + + await restApi.signIn({ + type: 'jwt', + siteName: config.siteName, + jwt, + }); } return restApi; @@ -73,14 +101,22 @@ export const useRestApi = async ({ server, callback, jwtScopes, + context, }: { config: Config; requestId: RequestId; server: Server; jwtScopes: Array; + context: ToolName | 'none'; callback: (restApi: RestApi) => Promise; }): Promise => { - const restApi = await getNewRestApiInstanceAsync(config, requestId, server, new Set(jwtScopes)); + const restApi = await getNewRestApiInstanceAsync({ + config, + requestId, + server, + jwtScopes: new Set(jwtScopes), + context, + }); try { return await callback(restApi); } finally { diff --git a/src/scripts/createClaudeDesktopExtensionManifest.ts b/src/scripts/createClaudeDesktopExtensionManifest.ts index b0a236e3..6f3e2553 100644 --- a/src/scripts/createClaudeDesktopExtensionManifest.ts +++ b/src/scripts/createClaudeDesktopExtensionManifest.ts @@ -107,6 +107,31 @@ const envVars = { required: false, sensitive: false, }, + JWT_PROVIDER_URL: { + includeInUserConfig: false, + type: 'string', + title: 'JWT Provider URL', + description: 'The URL of the JWT provider.', + required: false, + sensitive: false, + }, + JWT_PROVIDER_SECRET: { + includeInUserConfig: false, + type: 'string', + title: 'JWT Provider Secret', + description: 'The secret value to encrypt and provide on the secret header.', + required: false, + sensitive: true, + }, + JWT_PROVIDER_PUBLIC_KEY_PATH: { + includeInUserConfig: false, + type: 'string', + title: 'JWT Provider Secret Public Key Path', + description: + 'The path to the RSA public key (.pem) file used to encrypt the JWT provider secret header.', + required: false, + sensitive: false, + }, TRANSPORT: { includeInUserConfig: false, type: 'string', diff --git a/src/sdks/tableau/authConfig.ts b/src/sdks/tableau/authConfig.ts index 7ed0fb4b..96974008 100644 --- a/src/sdks/tableau/authConfig.ts +++ b/src/sdks/tableau/authConfig.ts @@ -15,4 +15,8 @@ export type AuthConfig = { scopes: Set; additionalPayload?: Record; } + | { + type: 'jwt'; + jwt: string; + } ); diff --git a/src/sdks/tableau/methods/authenticationMethods.ts b/src/sdks/tableau/methods/authenticationMethods.ts index 921fcfdd..99bedf29 100644 --- a/src/sdks/tableau/methods/authenticationMethods.ts +++ b/src/sdks/tableau/methods/authenticationMethods.ts @@ -52,6 +52,10 @@ export default class AuthenticationMethods extends Methods { }), ); } + +async function generateJwt(req: Request, res: Response): Promise { + const secret = req.headers['x-tabmcp-jwt-provider-secret']; + if (!secret || typeof secret !== 'string') { + res.status(401).json({ + error: 'Unauthorized', + }); + return; + } + + const privateKeyContents = readFileSync(process.env.PRIVATE_KEY_PATH!); + const privateKey = createPrivateKey({ + key: privateKeyContents, + format: 'pem', + passphrase: process.env.PRIVATE_KEY_PASSPHRASE, + }); + + const { plaintext } = await compactDecrypt(secret, privateKey); + if (!timingSafeEqual(plaintext, new TextEncoder().encode(process.env.JWT_PROVIDER_SECRET))) { + res.status(401).json({ + error: 'Unauthorized', + }); + return; + } + + const { username, scopes, source, resource, server, siteName } = req.body; + if (!username || !scopes || !source || !resource || !server || !siteName) { + res.status(400).json({ + error: 'username, scopes, source, resource, server, and siteName are required', + }); + return; + } + + const additionalPayload: Record = {}; + if (resource === 'query-datasource') { + additionalPayload.region = 'West'; + } + + const { connectedAppClientId, connectedAppSecretId, connectedAppSecretValue } = getConfig(); + const jwt = await getJwt({ + username: username as string, + connectedApp: { + clientId: connectedAppClientId, + secretId: connectedAppSecretId, + secretValue: connectedAppSecretValue, + }, + scopes: new Set(scopes as string[]), + additionalPayload, + }); + + res.json({ + jwt, + }); +} diff --git a/src/tools/listDatasources/listDatasources.ts b/src/tools/listDatasources/listDatasources.ts index b80b9701..992135f9 100644 --- a/src/tools/listDatasources/listDatasources.ts +++ b/src/tools/listDatasources/listDatasources.ts @@ -88,6 +88,7 @@ export const getListDatasourcesTool = (server: Server): Tool { const datasources = await paginate({ pageConfig: { diff --git a/src/tools/listFields.ts b/src/tools/listFields.ts index f8f8debd..462fb024 100644 --- a/src/tools/listFields.ts +++ b/src/tools/listFields.ts @@ -105,6 +105,7 @@ export const getListFieldsTool = (server: Server): Tool => requestId, server, jwtScopes: ['tableau:content:read'], + context: listFieldsTool.name, callback: async (restApi) => { return await restApi.metadataMethods.graphql(query); }, diff --git a/src/tools/pulse/generateMetricValueInsightBundle/generatePulseMetricValueInsightBundleTool.ts b/src/tools/pulse/generateMetricValueInsightBundle/generatePulseMetricValueInsightBundleTool.ts index 7dc346e7..d6f033e3 100644 --- a/src/tools/pulse/generateMetricValueInsightBundle/generatePulseMetricValueInsightBundleTool.ts +++ b/src/tools/pulse/generateMetricValueInsightBundle/generatePulseMetricValueInsightBundleTool.ts @@ -148,6 +148,7 @@ Generate an insight bundle for the current aggregated value for Pulse Metric usi requestId, server, jwtScopes: ['tableau:insights:read'], + context: generatePulseMetricValueInsightBundleTool.name, callback: async (restApi) => await restApi.pulseMethods.generatePulseMetricValueInsightBundle( bundleRequest, diff --git a/src/tools/pulse/listAllMetricDefinitions/listAllPulseMetricDefinitions.ts b/src/tools/pulse/listAllMetricDefinitions/listAllPulseMetricDefinitions.ts index 76353d79..7aba5ee0 100644 --- a/src/tools/pulse/listAllMetricDefinitions/listAllPulseMetricDefinitions.ts +++ b/src/tools/pulse/listAllMetricDefinitions/listAllPulseMetricDefinitions.ts @@ -56,6 +56,7 @@ Retrieves a list of all published Pulse Metric Definitions using the Tableau RES requestId, server, jwtScopes: ['tableau:insight_definitions_metrics:read'], + context: listAllPulseMetricDefinitionsTool.name, callback: async (restApi) => { return await restApi.pulseMethods.listAllPulseMetricDefinitions(view); }, diff --git a/src/tools/pulse/listMetricDefinitionsFromDefinitionIds/listPulseMetricDefinitionsFromDefinitionIds.ts b/src/tools/pulse/listMetricDefinitionsFromDefinitionIds/listPulseMetricDefinitionsFromDefinitionIds.ts index 68fde384..19fb7fab 100644 --- a/src/tools/pulse/listMetricDefinitionsFromDefinitionIds/listPulseMetricDefinitionsFromDefinitionIds.ts +++ b/src/tools/pulse/listMetricDefinitionsFromDefinitionIds/listPulseMetricDefinitionsFromDefinitionIds.ts @@ -67,6 +67,7 @@ Retrieves a list of specific Pulse Metric Definitions using the Tableau REST API requestId, server, jwtScopes: ['tableau:insight_definitions_metrics:read'], + context: listPulseMetricDefinitionsFromDefinitionIdsTool.name, callback: async (restApi) => { return await restApi.pulseMethods.listPulseMetricDefinitionsFromMetricDefinitionIds( metricDefinitionIds, diff --git a/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts b/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts index e3bdc6bb..f218d82d 100644 --- a/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts +++ b/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts @@ -42,6 +42,7 @@ Retrieves a list of published Pulse Metric Subscriptions for the current user us requestId, server, jwtScopes: ['tableau:metric_subscriptions:read'], + context: listPulseMetricSubscriptionsTool.name, callback: async (restApi) => { return await restApi.pulseMethods.listPulseMetricSubscriptionsForCurrentUser(); }, diff --git a/src/tools/pulse/listMetricsFromMetricDefinitionId/listPulseMetricsFromMetricDefinitionId.ts b/src/tools/pulse/listMetricsFromMetricDefinitionId/listPulseMetricsFromMetricDefinitionId.ts index eb45f5e2..9e172c24 100644 --- a/src/tools/pulse/listMetricsFromMetricDefinitionId/listPulseMetricsFromMetricDefinitionId.ts +++ b/src/tools/pulse/listMetricsFromMetricDefinitionId/listPulseMetricsFromMetricDefinitionId.ts @@ -44,6 +44,7 @@ Retrieves a list of published Pulse Metrics from a Pulse Metric Definition using requestId, server, jwtScopes: ['tableau:insight_definitions_metrics:read'], + context: listPulseMetricsFromMetricDefinitionIdTool.name, callback: async (restApi) => { return await restApi.pulseMethods.listPulseMetricsFromMetricDefinitionId( pulseMetricDefinitionID, diff --git a/src/tools/pulse/listMetricsFromMetricIds/listPulseMetricsFromMetricIds.ts b/src/tools/pulse/listMetricsFromMetricIds/listPulseMetricsFromMetricIds.ts index fee1787e..6686439f 100644 --- a/src/tools/pulse/listMetricsFromMetricIds/listPulseMetricsFromMetricIds.ts +++ b/src/tools/pulse/listMetricsFromMetricIds/listPulseMetricsFromMetricIds.ts @@ -48,6 +48,7 @@ Retrieves a list of published Pulse Metrics from a list of metric IDs using the requestId, server, jwtScopes: ['tableau:insight_metrics:read'], + context: listPulseMetricsFromMetricIdsTool.name, callback: async (restApi) => { return await restApi.pulseMethods.listPulseMetricsFromMetricIds(metricIds); }, diff --git a/src/tools/queryDatasource/queryDatasource.ts b/src/tools/queryDatasource/queryDatasource.ts index e78ccdc8..b0949ade 100644 --- a/src/tools/queryDatasource/queryDatasource.ts +++ b/src/tools/queryDatasource/queryDatasource.ts @@ -76,6 +76,7 @@ export const getQueryDatasourceTool = (server: Server): Tool { if (!config.disableQueryDatasourceFilterValidation) { // Validate filters values for SET and MATCH filters diff --git a/src/tools/readMetadata.ts b/src/tools/readMetadata.ts index 441ca7e3..37e536cc 100644 --- a/src/tools/readMetadata.ts +++ b/src/tools/readMetadata.ts @@ -47,6 +47,7 @@ export const getReadMetadataTool = (server: Server): Tool = requestId, server, jwtScopes: ['tableau:viz_data_service:read'], + context: readMetadataTool.name, callback: async (restApi) => { return await restApi.vizqlDataServiceMethods.readMetadata({ datasource: { diff --git a/src/tools/views/getViewData.ts b/src/tools/views/getViewData.ts index d4a0a724..7fd2851f 100644 --- a/src/tools/views/getViewData.ts +++ b/src/tools/views/getViewData.ts @@ -35,6 +35,7 @@ export const getGetViewDataTool = (server: Server): Tool => requestId, server, jwtScopes: ['tableau:views:download'], + context: getViewDataTool.name, callback: async (restApi) => { return await restApi.viewsMethods.queryViewData({ viewId, diff --git a/src/tools/views/getViewImage.ts b/src/tools/views/getViewImage.ts index 41fa1b0e..9a937e56 100644 --- a/src/tools/views/getViewImage.ts +++ b/src/tools/views/getViewImage.ts @@ -38,6 +38,7 @@ export const getGetViewImageTool = (server: Server): Tool = requestId, server, jwtScopes: ['tableau:views:download'], + context: getViewImageTool.name, callback: async (restApi) => { return await restApi.viewsMethods.queryViewImage({ viewId, diff --git a/src/tools/views/listViews.ts b/src/tools/views/listViews.ts index 7b960b89..3e4acdfa 100644 --- a/src/tools/views/listViews.ts +++ b/src/tools/views/listViews.ts @@ -78,6 +78,7 @@ export const getListViewsTool = (server: Server): Tool => { requestId, server, jwtScopes: ['tableau:content:read'], + context: listViewsTool.name, callback: async (restApi) => { const workbooks = await paginate({ pageConfig: { diff --git a/src/tools/workbooks/getWorkbook.ts b/src/tools/workbooks/getWorkbook.ts index 024028b7..90302278 100644 --- a/src/tools/workbooks/getWorkbook.ts +++ b/src/tools/workbooks/getWorkbook.ts @@ -35,6 +35,7 @@ export const getGetWorkbookTool = (server: Server): Tool => requestId, server, jwtScopes: ['tableau:content:read'], + context: getWorkbookTool.name, callback: async (restApi) => { const workbook = await restApi.workbooksMethods.getWorkbook({ workbookId, diff --git a/src/tools/workbooks/listWorkbooks.ts b/src/tools/workbooks/listWorkbooks.ts index c00d6d7c..1fd46190 100644 --- a/src/tools/workbooks/listWorkbooks.ts +++ b/src/tools/workbooks/listWorkbooks.ts @@ -75,6 +75,7 @@ export const getListWorkbooksTool = (server: Server): Tool requestId, server, jwtScopes: ['tableau:content:read'], + context: listWorkbooksTool.name, callback: async (restApi) => { const workbooks = await paginate({ pageConfig: { diff --git a/src/utils/getJwtFromProvider.ts b/src/utils/getJwtFromProvider.ts new file mode 100644 index 00000000..141009be --- /dev/null +++ b/src/utils/getJwtFromProvider.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; + +const jwtResponseSchema = z.object({ + jwt: z.string(), +}); + +const PROVIDER_SECRET_HEADER = 'x-tabmcp-jwt-provider-secret'; + +export async function getJwtFromProvider({ + jwtProviderUrl, + jwtProviderEncryptedSecret, + body, +}: { + jwtProviderUrl: string; + jwtProviderEncryptedSecret: string; + body: Record; +}): Promise { + try { + const response = await fetch(jwtProviderUrl, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + [PROVIDER_SECRET_HEADER]: jwtProviderEncryptedSecret, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`Failed to get JWT from provider: ${response.status} ${response.statusText}`); + } + + const json = await response.json(); + const result = jwtResponseSchema.safeParse(json); + + if (!result.success) { + throw new Error('Invalid JWT response, expected: { "jwt": "..." }'); + } + + return result.data.jwt; + } catch (error) { + throw new Error(`Failed to get JWT from provider: ${error}`); + } +} diff --git a/types/process-env.d.ts b/types/process-env.d.ts index 8ffb710d..8d3e1f1a 100644 --- a/types/process-env.d.ts +++ b/types/process-env.d.ts @@ -14,6 +14,9 @@ export interface ProcessEnvEx { CONNECTED_APP_SECRET_ID: string | undefined; CONNECTED_APP_SECRET_VALUE: string | undefined; JWT_ADDITIONAL_PAYLOAD: string | undefined; + JWT_PROVIDER_URL: string | undefined; + JWT_PROVIDER_SECRET: string | undefined; + JWT_PROVIDER_PUBLIC_KEY_PATH: string | undefined; DATASOURCE_CREDENTIALS: string | undefined; DEFAULT_LOG_LEVEL: string | undefined; DISABLE_LOG_MASKING: string | undefined;