diff --git a/docs/docs/configuration/mcp-config/oauth.md b/docs/docs/configuration/mcp-config/oauth.md
index 4c9164cc..4c4c7be1 100644
--- a/docs/docs/configuration/mcp-config/oauth.md
+++ b/docs/docs/configuration/mcp-config/oauth.md
@@ -66,11 +66,22 @@ The MCP transport type to use for the server.
### `SITE_NAME`
-The target Tableau site for OAuth.
+The target Tableau site for OAuth. The user must sign in to this site unless
+[`OAUTH_LOCK_SITE`](#oauth_lock_site) is `false`.
-- When [`AUTH`](#auth) is `oauth`, leaving this empty means any site will be supported, determined
- by the site the user signed into when connecting to the MCP server.
+
+
+### `OAUTH_LOCK_SITE`
+
+Whether to require the user to sign in to the site specified in [`SITE_NAME`](#site_name) when using
+OAuth.
+- Default: `true`
+- When `true`, the user must sign in to the site specified in [`SITE_NAME`](#site_name) (or the
+ Default site if empty).
+ - If the user already has an active Tableau session in their browser for a different site, an
+ error will be returned.
+- When `false`, the user can sign in to any site they can access.
### `OAUTH_REDIRECT_URI`
diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css
index ed57ee55..9f25610e 100644
--- a/docs/src/css/custom.css
+++ b/docs/src/css/custom.css
@@ -3,3 +3,8 @@
* bundles Infima by default. Infima is a CSS framework designed to
* work well for content-centric websites.
*/
+
+table td,
+table th {
+ vertical-align: top;
+}
diff --git a/package-lock.json b/package-lock.json
index 69636455..2f161798 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@tableau/mcp-server",
- "version": "1.13.10",
+ "version": "1.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@tableau/mcp-server",
- "version": "1.13.10",
+ "version": "1.14.0",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
diff --git a/package.json b/package.json
index b42d45bf..96e5ecff 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.13.10",
+ "version": "1.14.0",
"repository": {
"type": "git",
"url": "git+https://github.com/tableau/tableau-mcp.git"
diff --git a/src/config.test.ts b/src/config.test.ts
index 91286bb4..9d407feb 100644
--- a/src/config.test.ts
+++ b/src/config.test.ts
@@ -60,6 +60,7 @@ describe('Config', () => {
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,
@@ -1084,6 +1085,7 @@ describe('Config', () => {
clientIdSecretPairs: null,
issuer: defaultOAuthEnvVars.OAUTH_ISSUER,
redirectUri: `${defaultOAuthEnvVars.OAUTH_ISSUER}/Callback`,
+ lockSite: true,
jwePrivateKey: '',
jwePrivateKeyPath: defaultOAuthEnvVars.OAUTH_JWE_PRIVATE_KEY_PATH,
jwePrivateKeyPassphrase: undefined,
@@ -1103,6 +1105,7 @@ describe('Config', () => {
issuer: '',
clientIdSecretPairs: null,
redirectUri: '',
+ lockSite: true,
jwePrivateKey: '',
jwePrivateKeyPath: '',
jwePrivateKeyPassphrase: undefined,
@@ -1160,6 +1163,20 @@ 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',
+ };
+
+ const config = new Config();
+ expect(config.oauth).toEqual({
+ ...defaultOAuthConfig,
+ lockSite: false,
+ });
+ });
+
it('should set jwePrivateKey to the specified value when OAUTH_JWE_PRIVATE_KEY is set', () => {
process.env = {
...process.env,
diff --git a/src/config.ts b/src/config.ts
index 677b27a9..8e0093ac 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -68,6 +68,7 @@ export class Config {
enabled: boolean;
issuer: string;
redirectUri: string;
+ lockSite: boolean;
jwePrivateKey: string;
jwePrivateKeyPath: string;
jwePrivateKeyPassphrase: string | undefined;
@@ -122,6 +123,7 @@ export class Config {
TABLEAU_SERVER_VERSION_CHECK_INTERVAL_IN_HOURS: tableauServerVersionCheckIntervalInHours,
DANGEROUSLY_DISABLE_OAUTH: disableOauth,
OAUTH_ISSUER: oauthIssuer,
+ OAUTH_LOCK_SITE: oauthLockSite,
OAUTH_JWE_PRIVATE_KEY: oauthJwePrivateKey,
OAUTH_JWE_PRIVATE_KEY_PATH: oauthJwePrivateKeyPath,
OAUTH_JWE_PRIVATE_KEY_PASSPHRASE: oauthJwePrivateKeyPassphrase,
@@ -193,6 +195,7 @@ export class Config {
enabled: disableOauthOverride ? false : !!oauthIssuer,
issuer: oauthIssuer ?? '',
redirectUri: redirectUri || (oauthIssuer ? `${oauthIssuer}/Callback` : ''),
+ lockSite: oauthLockSite !== 'false', // Site locking is enabled by default
jwePrivateKey: oauthJwePrivateKey ?? '',
jwePrivateKeyPath: oauthJwePrivateKeyPath ?? '',
jwePrivateKeyPassphrase: oauthJwePrivateKeyPassphrase || undefined,
diff --git a/src/scripts/createClaudeMcpBundleManifest.ts b/src/scripts/createClaudeMcpBundleManifest.ts
index 70d8cb62..69c363fc 100644
--- a/src/scripts/createClaudeMcpBundleManifest.ts
+++ b/src/scripts/createClaudeMcpBundleManifest.ts
@@ -396,6 +396,14 @@ const envVars = {
required: false,
sensitive: false,
},
+ OAUTH_LOCK_SITE: {
+ includeInUserConfig: false,
+ type: 'boolean',
+ title: 'OAuth Lock Site',
+ description: 'Whether to lock the site when using OAuth.',
+ required: false,
+ sensitive: false,
+ },
OAUTH_CLIENT_ID_SECRET_PAIRS: {
includeInUserConfig: false,
type: 'string',
diff --git a/src/server/oauth/authorize.ts b/src/server/oauth/authorize.ts
index 99d64b5f..d6abfef8 100644
--- a/src/server/oauth/authorize.ts
+++ b/src/server/oauth/authorize.ts
@@ -133,10 +133,50 @@ export function authorize(
oauthUrl.searchParams.set('device_name', getDeviceName(redirect_uri, state ?? ''));
oauthUrl.searchParams.set('client_type', 'tableau-mcp');
- res.redirect(oauthUrl.toString());
+ if (config.oauth.lockSite) {
+ // The "redirected" parameter is used by Tableau's OAuth controller to determine whether the user will be shown the site picker.
+ // When provided, the user will not be shown the site picker.
+ oauthUrl.searchParams.set('redirected', 'true');
+ }
+
+ const redirectUrl = await getOAuthRedirectUrl(oauthUrl, { lockSite: config.oauth.lockSite });
+ res.redirect(redirectUrl.toString());
});
}
+async function getOAuthRedirectUrl(
+ initialOAuthUrl: URL,
+ { lockSite }: { lockSite: boolean },
+): Promise {
+ if (lockSite) {
+ // When the site is locked, Tableau does the right thing and never shows the site picker,
+ // regardless of whether the user already has an active Tableau session in their browser.
+ return initialOAuthUrl;
+ }
+
+ // When the site is not locked, Tableau does the right thing and shows the site picker, but only on Cloud.
+ // On Server, if the user does not have an active Tableau session in their browser,
+ // Tableau does not show the site picker.
+ // We can force it to by changing the path from #/signin to #/site.
+
+ try {
+ const response = await fetch(initialOAuthUrl, { redirect: 'manual' });
+ if (response.status === 302) {
+ // The response is a redirect to the Tableau OAuth login page.
+ // Force it to ultimately show the site picker by changing the path from #/signin to #/site.
+ const location = response.headers.get('location');
+ if (location?.startsWith('#/signin') || location?.startsWith('/#/signin')) {
+ const locationUrl = new URL(location.replace('#/signin', '#/site'), initialOAuthUrl.origin);
+ return locationUrl;
+ }
+ }
+ } catch {
+ return initialOAuthUrl;
+ }
+
+ return initialOAuthUrl;
+}
+
// https://client.dev/servers
async function getClientFromMetadataDoc(
clientMetadataUrl: URL,
diff --git a/src/server/oauth/callback.ts b/src/server/oauth/callback.ts
index 13c42752..b37d3e4b 100644
--- a/src/server/oauth/callback.ts
+++ b/src/server/oauth/callback.ts
@@ -39,10 +39,19 @@ export function callback(
const { error, code, state } = result.data;
if (error) {
- res.status(400).json({
- error: 'access_denied',
- error_description: 'User denied authorization',
- });
+ if (error === 'invalid_request') {
+ res.status(400).json({
+ error: 'invalid_request',
+ error_description:
+ 'Invalid request. Did you sign in to the wrong site? From your browser, please sign out of your site and reconnect your agent to Tableau MCP.',
+ });
+ } else {
+ res.status(400).json({
+ error: 'access_denied',
+ error_description: 'User denied authorization',
+ });
+ }
+
return;
}
@@ -114,6 +123,24 @@ export function callback(
return;
}
+ if (
+ config.oauth.lockSite &&
+ sessionResult.value.site.name !== config.siteName &&
+ !(sessionResult.value.site.name === 'Default' && !config.siteName)
+ ) {
+ const sentences = [
+ `User signed in to site: ${sessionResult.value.site.name || 'Default'}.`,
+ `Expected site: ${config.siteName || 'Default'}.`,
+ `Please reconnect your client and choose the [${config.siteName || 'Default'}] site in the site picker if prompted.`,
+ ];
+
+ res.status(400).json({
+ error: 'invalid_request',
+ error_description: sentences.join(' '),
+ });
+ return;
+ }
+
// Generate authorization code
const authorizationCode = randomBytes(32).toString('hex');
authorizationCodes.set(authorizationCode, {
diff --git a/tests/oauth/authorizationCodeCallback.test.ts b/tests/oauth/authorizationCodeCallback.test.ts
index 469605dd..aa33f996 100644
--- a/tests/oauth/authorizationCodeCallback.test.ts
+++ b/tests/oauth/authorizationCodeCallback.test.ts
@@ -27,6 +27,7 @@ describe('authorization code callback', () => {
});
afterEach(async () => {
+ vi.unstubAllEnvs();
await new Promise((resolve) => {
if (_server) {
_server.close(() => {
@@ -49,6 +50,22 @@ describe('authorization code callback', () => {
return { app };
}
+ it('should reject if the request is invalid', async () => {
+ const { app } = await startServer();
+
+ const response = await request(app).get('/Callback').query({
+ error: 'invalid_request',
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.headers['content-type']).toBe('application/json; charset=utf-8');
+ expect(response.body).toEqual({
+ error: 'invalid_request',
+ error_description:
+ 'Invalid request. Did you sign in to the wrong site? From your browser, please sign out of your site and reconnect your agent to Tableau MCP.',
+ });
+ });
+
it('should reject if user denies authorization', async () => {
const { app } = await startServer();
@@ -152,7 +169,95 @@ describe('authorization code callback', () => {
});
});
- it('should issue an authorization code when the Tableau access token is successfully retrieved', async () => {
+ it('should reject if the user signs in to a different site other than the locked, expected site', async () => {
+ vi.stubEnv('SITE_NAME', 'other-site');
+
+ const { app } = await startServer();
+
+ const authzResponse = await request(app).get('/oauth/authorize').query({
+ client_id: 'test-client-id',
+ redirect_uri: 'http://localhost:3000',
+ response_type: 'code',
+ code_challenge: 'test-code-challenge',
+ code_challenge_method: 'S256',
+ state: 'test-state',
+ });
+
+ const authzLocation = new URL(authzResponse.headers['location']);
+ const [authKey, tableauState] = authzLocation.searchParams.get('state')?.split(':') ?? [];
+
+ mocks.mockGetTokenResult.mockResolvedValue({
+ accessToken: 'test-access-token',
+ refreshToken: 'test-refresh-token',
+ expiresInSeconds: 3600,
+ originHost: '10ax.online.tableau.com',
+ });
+
+ const response = await request(app)
+ .get('/Callback')
+ .query({
+ code: 'test-code',
+ state: `${authKey}:${tableauState}`,
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.headers['content-type']).toBe('application/json; charset=utf-8');
+ expect(response.body).toEqual({
+ error: 'invalid_request',
+ error_description:
+ 'User signed in to site: mcp-test. Expected site: other-site. Please reconnect your client and choose the [other-site] site in the site picker if prompted.',
+ });
+ });
+
+ it('should issue an authorization code when the Tableau access token is successfully retrieved when site locking is disabled', async () => {
+ vi.stubEnv('SITE_NAME', 'other-site');
+ vi.stubEnv('OAUTH_LOCK_SITE', 'false');
+
+ const { app } = await startServer();
+
+ const authzResponse = await request(app).get('/oauth/authorize').query({
+ client_id: 'test-client-id',
+ redirect_uri: 'http://localhost:3000',
+ response_type: 'code',
+ code_challenge: 'test-code-challenge',
+ code_challenge_method: 'S256',
+ state: 'test-state',
+ });
+
+ const authzLocation = new URL(authzResponse.headers['location']);
+ const searchParams = new URLSearchParams(
+ authzLocation.hash.substring(authzLocation.hash.indexOf('?')),
+ );
+
+ const externalRedirectUrl = new URL(
+ searchParams.get('externalRedirect') ?? '',
+ authzLocation.origin,
+ );
+
+ const [authKey, tableauState] = externalRedirectUrl.searchParams.get('state')?.split(':') ?? [];
+
+ mocks.mockGetTokenResult.mockResolvedValue({
+ accessToken: 'test-access-token',
+ refreshToken: 'test-refresh-token',
+ expiresInSeconds: 3600,
+ originHost: '10ax.online.tableau.com',
+ });
+
+ const response = await request(app)
+ .get('/Callback')
+ .query({
+ code: 'test-code',
+ state: `${authKey}:${tableauState}`,
+ });
+
+ expect(response.status).toBe(302);
+ const location = new URL(response.headers['location']);
+ expect(location.origin).toBe('http://localhost:3000');
+ expect(location.searchParams.get('code')).not.toBeNull();
+ expect(location.searchParams.get('state')).toBe('test-state');
+ });
+
+ it('should issue an authorization code when the Tableau access token is successfully retrieved when site locking is enabled', async () => {
const { app } = await startServer();
const authzResponse = await request(app).get('/oauth/authorize').query({
diff --git a/tests/oauth/testSetup.ts b/tests/oauth/testSetup.ts
index 80a6f937..28d0e29c 100644
--- a/tests/oauth/testSetup.ts
+++ b/tests/oauth/testSetup.ts
@@ -13,7 +13,7 @@ vi.mock('../../src/sdks/tableau/restApi.js', async (importOriginal) => ({
Ok({
site: {
id: 'site_id',
- name: 'test-site',
+ name: 'mcp-test',
},
user: {
id: 'user_id',
diff --git a/types/process-env.d.ts b/types/process-env.d.ts
index eefc11c6..4852b5c5 100644
--- a/types/process-env.d.ts
+++ b/types/process-env.d.ts
@@ -45,6 +45,7 @@ export interface ProcessEnvEx {
OAUTH_JWE_PRIVATE_KEY_PASSPHRASE: string | undefined;
OAUTH_CIMD_DNS_SERVERS: string | undefined;
OAUTH_REDIRECT_URI: string | undefined;
+ OAUTH_LOCK_SITE: string | undefined;
OAUTH_CLIENT_ID_SECRET_PAIRS: string | undefined;
OAUTH_AUTHORIZATION_CODE_TIMEOUT_MS: string | undefined;
OAUTH_ACCESS_TOKEN_TIMEOUT_MS: string | undefined;