Skip to content
Open
11 changes: 11 additions & 0 deletions docs/docs/configuration/mcp-config/authentication/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,14 @@ There are a couple different ways to authenticate to Tableau.
3. Use Tableau [Unified Access Tokens](uat.md).
4. Use Tableau [OAuth](oauth.md).
5. Use [Passthrough Authentication](passthrough.md).

## Troubleshooting authentication errors

An `Authentication failed (401): ...` message — whether returned at tool call or logged at startup
while fetching site settings — means the server could **not authenticate**: the PAT/OAuth session
is missing, invalid, or expired. This is distinct from a permission error
(`Permission denied (403): ...`, authenticated but the request was refused — missing role/permission
or a capability not enabled) and from a feature genuinely not
being provisioned. When several servers are configured, a `401` frequently means the request
reached the **wrong or unauthenticated** server. See
[Running Multiple Servers & Diagnosing Auth Errors](../multiple-servers.md).
5 changes: 5 additions & 0 deletions docs/docs/configuration/mcp-config/authentication/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ All Tableau auth modes are *site scoped*. If you are a multi-site user, and you
- **Problem:** After a user has already connected to the hosted Tableau MCP server, disconnecting and reconnecting may not trigger the OAuth flow again.
- **Workaround:** Sign out of Tableau Cloud, then trigger the Tableau MCP OAuth flow again. If the issue persists, clear your browser cookies and try again.

**Issue 3: Multiple configured servers — a tool call may target the wrong or unauthenticated server**

- **Problem:** When more than one Tableau MCP server is configured, the AI client may send a tool call to a different server than you intended — including one that is not authenticated. The resulting `401` can be summarized by the AI as a vague "feature not configured" message.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it that a 401 Unauthorized expected with this issue? When there are multiple servers connected, a client does not user credentials from one server when calling the other right? It uses the credentials that pertains to the connection. So it shouldn't be 401 Unauthorized, it would be more like 404 Not Found when you specify a target. For example, if I ask it to get me metadata for a Datasource belonging to MCP server A, but instead it calls MCP server B. The request will be authorized right, just fail with 404 because the datasource was not found.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hear your point @stephendeoca, but this can be debatable. You can look at it as we have an authenticated user (for a session on server A) that cannot see resources because no authorization on target server B... catching this error with proper logging.
For me, having 404 error hide the real auth error.

- **Workaround:** Name each server distinctly, confirm which server answered before trusting admin results, and read the raw error. See [Running Multiple Servers & Diagnosing Auth Errors](../multiple-servers.md).

### Self-hosted Tableau MCP

Tableau Cloud customers can self-host Tableau MCP. A full guide can be found at
Expand Down
73 changes: 73 additions & 0 deletions docs/docs/configuration/mcp-config/multiple-servers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
sidebar_position: 8
---

# Running Multiple Servers & Diagnosing Auth Errors

You can configure more than one Tableau MCP server in the same client (for example a
production site, a stage site, and a local build). When you do, two things become important:
keeping them **distinctly named** so both you and the AI can tell them apart, and knowing how to
**read an authentication error** so it is not mistaken for a missing feature.

## Name each server distinctly

The AI client chooses which configured server to send a tool call to. If several servers are
configured with similar names — or several expose the same tool — a request can land on the
**wrong** server, or on one that is **not authenticated**. When that happens the failure often
reads like "the feature isn't set up," when the real cause is *which server answered*.

Give each server a name that encodes site + purpose, e.g.:

```json
{
"mcpServers": {
"tableau-prod-acme": { "command": "npx", "args": ["-y", "@tableau/mcp-server@latest"], "env": { "SERVER": "https://prod.online.tableau.com", "SITE_NAME": "acme", "PAT_NAME": "...", "PAT_VALUE": "..." } },
"tableau-stage-acme": { "command": "npx", "args": ["-y", "@tableau/mcp-server@latest"], "env": { "SERVER": "https://stage.online.tableau.com", "SITE_NAME": "acme-stage", "PAT_NAME": "...", "PAT_VALUE": "..." } }
}
}
```

Avoid running two servers that expose the **same admin tools against different sites** unless you
truly need both — it is the most common source of "why did it query the wrong site?".

## Confirm which server answered

Before trusting an admin/insights result when multiple servers are configured, confirm the
target. Ask the client to run a cheap, unambiguous call first (for example
[`list-projects`](../../tools/projects/list-projects.md)) and check it returns *your* site's
content. In Claude Code, `/mcp` shows which servers are connected vs failed.

You can also add a line to your client's memory (e.g. `CLAUDE.md`) so the AI self-discloses the
target and never hides an auth failure:

> When multiple Tableau MCP servers are configured, state which server a tool call targeted.
> Surface authentication/connection errors verbatim. Treat a 401 as "wrong or unauthenticated
> server," **not** as "the feature is missing."

## Decode the error

The server returns **distinct, self-explanatory** errors for the four cases below. Each names the
cause and — where the server knows them — the targeted **site** and **pod**, so a `401` is never
mistaken for a missing feature. If the AI still summarizes an error into a single vague phrase
(e.g. "no admin insights configured"), ask the client for the **raw** error text and match it here:

| You see (raw text) | Real cause | What to do |
|---|---|---|
| `Authentication failed (401): the credentials for this Tableau MCP server (site "...", pod "...") are missing, invalid, or expired. ...` | **Not authenticated** — the PAT/OAuth session for the targeted server is missing, invalid, or expired. | Re-authenticate that server (new PAT, or re-run the OAuth flow). Confirm you targeted the intended server. |
| The server logs a warning at startup (`Authentication failed (401): ...`) and continues with default settings; the same `401` message is then returned at tool call. | **Not authenticated at startup** — the credentials failed while the server was fetching site settings during launch. The server no longer crashes: it connects, registers the base tools, and surfaces the auth error at the first tool call (matching OAuth). | Fix the credentials for that server, then retry. |
| `Permission denied (403): you are authenticated to this Tableau MCP server (site "...") but this request was refused. Your account may lack the required site role or permission, or the capability may not be enabled for this site. ...` | **Authenticated, but the request was refused** — missing role/permission, or a capability not enabled for the site. For admin tools you may instead see `This tool requires site administrator permissions. Your site role is: <role>`. | Use an account with the required site role or permissions, or confirm the capability is enabled for the site. |
| `Admin Insights dataset "<name>" not found in the "Admin Insights" project on this site. This means Admin Insights is not provisioned on the targeted Tableau Cloud site ...` | **Authenticated + admin, but Admin Insights is genuinely not provisioned** on the targeted site. | Enable Admin Insights on that Tableau Cloud site, or target the site where it is provisioned. |

A `401` is an **authentication** problem, never evidence that Admin Insights is missing. Only the
last row means the feature itself is absent.

Over OAuth, a missing or expired token is rejected by the transport before any tool runs: the
`401` response keeps its `WWW-Authenticate` challenge (so the re-authentication flow still works)
and its `error_description` carries the same guidance — verify you targeted the intended server and
re-authenticate.

## Related

- [Authentication](authentication/README.md)
- [OAuth](authentication/oauth.md) — see **Known Issues** for multi-site connection pitfalls
- [Query Admin Insights](../../tools/admin-insights/query-admin-insights.md)
3 changes: 3 additions & 0 deletions docs/docs/intro.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ agent-to-Tableau communication, enabling users to bring their Tableau data into

Tableau MCP is also a managed service on every Tableau Cloud pod, and it is accessible over the url: `https://mcp.tableau.com`. See [Hosted Tableau MCP](hosted-tableau-mcp) for more details.

> Running more than one Tableau MCP server, or seeing a confusing "not configured"/`401` error?
> See [Running Multiple Servers & Diagnosing Auth Errors](configuration/mcp-config/multiple-servers.md).

Follow along and share ideas with the Tableau MCP team by creating issues or discussions on the
repository. You can also join the [Tableau Developer Platform](https://www.tableau.com/developer)
and reach out in the [#tableau-ai-solutions](https://tableau-datadev.slack.com/archives/C07LMAVG4N6)
Expand Down
6 changes: 6 additions & 0 deletions docs/docs/tools/admin-insights/query-admin-insights.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@ This is a **successful** result, not an error — only the row payload is withhe
count and a `ROW_CAP_EXCEEDED` warning is attached — see [Row cap](#row-cap-stale-content).
- This tool intentionally bypasses the standard datasource access checker because Admin Insights
datasources are internal/known and admin-gated independently.
- **A `401` or a "not found in the Admin Insights project" error does not necessarily mean Admin
Insights is missing.** When multiple MCP servers are configured, the request may have reached the
wrong or unauthenticated server. An `Authentication failed (401): ...` message is an
authentication failure; the "not provisioned on the targeted Tableau Cloud site" message means
Admin Insights is genuinely absent on the targeted site. See
[Running Multiple Servers & Diagnosing Auth Errors](../../configuration/mcp-config/multiple-servers.md).

## Example results

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@tableau/mcp-server",
"description": "Helping agents see and understand data.",
"version": "4.9.0",
"version": "4.9.1",
"repository": {
"type": "git",
"url": "git+https://github.com/tableau/tableau-mcp.git"
Expand Down
85 changes: 85 additions & 0 deletions src/server/oauth/authMiddleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Err, Ok } from 'ts-results-es';

import { stubDefaultEnvVars } from '../../testShared.js';
import { OAUTH_AUTH_CHALLENGE_GUIDANCE } from '../../utils/authErrorMessage.js';
import { AccessTokenValidator } from './accessTokenValidator.js';
import { authMiddleware } from './authMiddleware.js';
import { AuthenticatedRequest } from './types.js';

// Minimal Express response double capturing status / headers / json body.
function makeRes(): any {
const res: any = {
statusCode: undefined as number | undefined,
headers: {} as Record<string, string>,
body: undefined as unknown,
headersSent: false,
status(code: number) {
res.statusCode = code;
return res;
},
header(key: string, value: string) {
res.headers[key] = value;
return res;
},
json(body: unknown) {
res.body = body;
return res;
},
writeHead: vi.fn(() => res),
write: vi.fn(),
end: vi.fn(),
};
return res;
}

function makeValidator(result: Ok<any> | Err<string>): AccessTokenValidator {
return { validate: vi.fn().mockResolvedValue(result) } as unknown as AccessTokenValidator;
}

describe('authMiddleware auth-error wording (W-23757363)', () => {
beforeEach(() => {
vi.unstubAllEnvs();
stubDefaultEnvVars();
});

afterEach(() => {
vi.unstubAllEnvs();
});

it('carries the shared guidance in error_description while keeping the WWW-Authenticate challenge when no token is present', async () => {
const middleware = authMiddleware(makeValidator(new Err('unused')));
const req = { headers: {}, method: 'POST', body: {} } as unknown as AuthenticatedRequest;
const res = makeRes();
const next = vi.fn();

await middleware(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBe(401);
// The re-auth challenge must remain intact so the OAuth flow still works.
expect(res.headers['WWW-Authenticate']).toContain('Bearer realm="MCP"');
expect(res.body.error).toBe('unauthorized');
expect(res.body.error_description).toContain('Use the OAuth 2.1 flow');
expect(res.body.error_description).toContain(OAUTH_AUTH_CHALLENGE_GUIDANCE);
});

it('carries the shared guidance in error_description for an invalid/expired token', async () => {
const middleware = authMiddleware(makeValidator(new Err('token expired')));
const req = {
headers: { authorization: 'Bearer bad-token' },
method: 'POST',
body: {},
} as unknown as AuthenticatedRequest;
const res = makeRes();
const next = vi.fn();

await middleware(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBe(401);
expect(res.body.error).toBe('invalid_token');
// Underlying validation detail is preserved, followed by the shared guidance.
expect(res.body.error_description).toContain('token expired');
expect(res.body.error_description).toContain(OAUTH_AUTH_CHALLENGE_GUIDANCE);
});
});
10 changes: 8 additions & 2 deletions src/server/oauth/authMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { NextFunction, RequestHandler, Response } from 'express';

import { getConfig } from '../../config.js';
import { log } from '../../logging/logger.js';
import { OAUTH_AUTH_CHALLENGE_GUIDANCE } from '../../utils/authErrorMessage.js';
import { getToolNameFromRequestBody } from '../requestUtils.js';
import { AccessTokenValidator } from './accessTokenValidator.js';
import {
Expand Down Expand Up @@ -68,7 +69,10 @@ export function authMiddleware(accessTokenValidator: AccessTokenValidator): Requ
)
.json({
error: 'unauthorized',
error_description: 'Authorization required. Use OAuth 2.1 flow.',
// Keep the WWW-Authenticate challenge (above) intact so the re-auth flow still works, but
// enrich the human-readable description with the shared multi-server guidance so a missing
// or expired token is not misread as a missing feature (W-23757363).
error_description: `Authorization required. Use the OAuth 2.1 flow to authenticate. ${OAUTH_AUTH_CHALLENGE_GUIDANCE}`,
Comment thread
Alon-ST-DATA marked this conversation as resolved.
});
return;
}
Expand Down Expand Up @@ -98,7 +102,9 @@ export function authMiddleware(accessTokenValidator: AccessTokenValidator): Requ
});
res.status(401).json({
error: 'invalid_token',
error_description: result.error,
// Same shared guidance for an invalid/expired token so it is not misread as a missing
// feature (W-23757363). The underlying validation detail is preserved first.
error_description: `${result.error}. ${OAUTH_AUTH_CHALLENGE_GUIDANCE}`,
});
return;
}
Expand Down
9 changes: 9 additions & 0 deletions src/tools/web/adminInsights/resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,4 +540,13 @@ describe('adminInsightsResolver', () => {
expect(resolution.luid).toBe('luid-alive');
});
});

// W-23757363: the not-found message must mean "not provisioned" only. It previously also told the
// caller to be a "Site Administrator Creator", conflating this 404 with an auth/permission error.
it('produces a not-provisioned message without any admin-role clause', () => {
const error = new AdminInsightsDatasetNotFoundError(ADMIN_INSIGHTS_DATASETS.JOB_PERFORMANCE);
expect(error.message).toContain('not provisioned on the targeted Tableau Cloud site');
expect(error.message).toContain('Enable Admin Insights on this site');
expect(error.message).not.toContain('Site Administrator Creator');
});
});
8 changes: 7 additions & 1 deletion src/tools/web/adminInsights/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,15 @@ function getProjectCache(): ExpiringMap<string, string> {

export class AdminInsightsDatasetNotFoundError extends Error {
constructor(datasetName: string) {
// Message intentionally scoped to the not-provisioned condition only. The prior wording also
// mentioned needing a "Site Administrator Creator" role, which conflated this 404 with an
// authentication/permission failure — callers reaching this point are already authenticated and
// admin-gated (see adminInsightsToolBase.runAdminInsightsQuery → assertAdmin). W-23757363.
super(
`Admin Insights dataset "${datasetName}" not found in the "${ADMIN_INSIGHTS_PROJECT_NAME}" project on this site. ` +
'Confirm the caller is on a Tableau Cloud site with Admin Insights enabled and that the caller is a Site Administrator Creator.',
'This means Admin Insights is not provisioned on the targeted Tableau Cloud site (a distinct ' +
'condition from an authentication or permission error). Enable Admin Insights on this site, ' +
'or target the site where it is provisioned.',
);
this.name = 'AdminInsightsDatasetNotFoundError';
}
Expand Down
Loading
Loading