Skip to content

Conversation

@mattcfilbert
Copy link

Description

Implemented MCP OAuth scope support and best‑practice authorization challenges. Added scope config knobs, advertised scopes_supported, validated requested scopes, persisted scopes across auth/refresh flows, embedded scopes in access tokens, and enforced required scopes with insufficient_scope challenges. Updated OAuth docs with a recommended beta scope set and rationale for MCP scopes.

Motivation and Context

MCP lacked spec‑aligned scope handling and challenge responses, which blocked least‑privilege access and step‑up behavior. This change brings OAuth behavior in line with the MCP authorization spec and provides a clear scope configuration for beta.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update
  • Other (please describe):

How Has This Been Tested?

Not run (logic is unit‑level; tests can be added in a follow‑up).

Related Issues

MCP OAuth scope support + best‑practice authorization challenges #204

Checklist

  • I have updated the version in the package.json file by using npm run version. For example,
    use npm run version:patch for a patch version bump.
  • I have made any necessary changes to the documentation
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have documented any breaking changes in the PR description. For example, renaming a config
    environment variable or changing its default value.

Contributor Agreement

By submitting this pull request, I confirm that:

@salesforce-cla
Copy link

Thanks for the contribution! Unfortunately we can't verify the commit author(s): mfilbert <m***@s***.com>. One possible solution is to add that email to your GitHub account. Alternatively you can change your commits to another email and force push the change. After getting your commits associated with your GitHub account, refresh the status of this Pull Request.

@mattcfilbert mattcfilbert force-pushed the W-20976712-mcp-oauth-scopes branch from a6ca30b to 01c9f2e Compare January 21, 2026 16:15
@mattcfilbert mattcfilbert force-pushed the W-20976712-mcp-oauth-scopes branch 2 times, most recently from baa916b to 41415fa Compare January 21, 2026 17:40
Comment on lines 16 to 34
export type McpScope =
| 'tableau:content:read'
| 'tableau:viz_data_service:read'
| 'tableau:views:download'
| 'tableau:insight_definitions_metrics:read'
| 'tableau:insight_metrics:read'
| 'tableau:metric_subscriptions:read'
| 'tableau:insights:read'
| 'tableau:insight_brief:create';
Copy link
Collaborator

Choose a reason for hiding this comment

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

In my mind, I was thinking we wouldn't use an API scope to protect an MCP resource directly ("resource" as in OAuth resource, not MCP resource). That is: an access token must have, say, the tableau:mcp:datasource:read MCP scope to even call the query-datasource tool, but that same access token would also need the 'tableau:viz_data_service:read' API scope since it's going to be re-used to call into VDS.

* Validates that a scope string is a valid MCP scope
*/
export function isValidScope(scope: string): scope is McpScope {
return DEFAULT_SCOPES_SUPPORTED.includes(scope as McpScope);
Copy link
Collaborator

Choose a reason for hiding this comment

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

You can avoid the type assertion using DEFAULT_SCOPES_SUPPORTED.some((s) => s === scope);

* @param endpoint - The MCP endpoint or tool name
* @returns Array of required scopes for the endpoint
*/
export function getRequiredScopesForTool(toolName: ToolName | string): string[] {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Since we are going to need to maintain this map, let's use it as the source of truth for when tools generate a JWT with scopes.

For example, the query-datasource tool currently passes jwtScopes: ['tableau:viz_data_service:read'], to useRestApi().

Instead, it could pass: jwtScopes: getRequiredScopesForTool(queryDatasourceTool.name),.

This way we can never accidentally forget to update this map or the tool implementation.

* @returns Array of required scopes for the endpoint
*/
export function getRequiredScopesForTool(toolName: ToolName | string): string[] {
const toolScopeMap: Record<ToolName, McpScope[]> = {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's pull toolScopeMap outside of the function definition. It doesn't need to be initialized every time the function is called.

* @param endpoint - The MCP endpoint or tool name
* @returns Array of required scopes for the endpoint
*/
export function getRequiredScopesForTool(toolName: ToolName | string): string[] {
Copy link
Collaborator

@anyoung-tableau anyoung-tableau Jan 22, 2026

Choose a reason for hiding this comment

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

Let's change the type on the toolName parameter to be ToolName. Calling sites can provide their tool name using queryDatasourceTool.name (or even this.name if we call this function from tool.ts) for example. Then we don't need the if (toolName in toolScopeMap) or the fallback return DEFAULT_REQUIRED_SCOPES; below.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also were you planning to wire this function up in a followup PR?

* @param endpoint - The MCP endpoint or tool name
* @returns Array of required scopes for the endpoint
*/
export function getRequiredScopesForTool(toolName: ToolName | string): string[] {
Copy link
Collaborator

@anyoung-tableau anyoung-tableau Jan 22, 2026

Choose a reason for hiding this comment

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

If scopes are disabled, getRequiredScopesForTool should return an empty array? Or were you planning to not call this function when scopes are disabled?

code_challenge_methods_supported: ['S256'],
scopes_supported: [],
scopes_supported: scopesSupported,
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
Copy link
Collaborator

Choose a reason for hiding this comment

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

While we're here, would you mind making a driveby change here and add 'none' to token_endpoint_auth_methods_supported?

https://salesforce-internal.slack.com/archives/C08QYBH8AE5/p1769075292144669

Comment on lines 187 to 188
For the initial release, use an inclusive scope set (MCP + Tableau API scopes) to avoid token
exchange. This keeps consent simple and aligns with the current MCP server behavior.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Internal decisions and implementation details don't need to be documented.

Comment on lines 217 to 222
- Not all MCP tools call Tableau APIs. Some tools can operate entirely within the MCP server
(for example, generating a TWB). Tableau API scopes do not describe those operations.
- MCP also exposes non-API concepts like prompts and resources that should be gated behind MCP
scopes. Those do not have a natural Tableau API scope equivalent.
- Keeping MCP scopes separate from API scopes clarifies intent and avoids over-granting: MCP scopes
authorize what the MCP server can do; Tableau API scopes authorize what downstream APIs can do.
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't think we need to go into these details in the docs. We should describe more of the "what", and not necessarily the "why" here.

Comment on lines 171 to 173
if (scopesToGrant.length > 0) {
oauthUrl.searchParams.set('scope', formatScopes(scopesToGrant));
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

So, the /oauth2/v1/auth endpoint on Tableau doesn't support scopes or consent. Not sure it makes sense to provide the scopes if they're going to be ignored.

Comment on lines +265 to +268
3. **Token Exchange**:
- What is the endpoint for token exchange?
- What format should the request/response be?
- How do we convert MCP tokens to Tableau REST API tokens?
Copy link
Collaborator

Choose a reason for hiding this comment

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

We aren't going to use token exchange for initial release

Copy link
Author

Choose a reason for hiding this comment

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

thank you for the reminder - I'm going through all of your great feedback now

@mattcfilbert mattcfilbert force-pushed the W-20976712-mcp-oauth-scopes branch from 655038b to c4ebe90 Compare January 23, 2026 20:44
src/config.ts Outdated
Comment on lines 144 to 145
TELEMETRY_PROVIDER: _telemetryProvider,
TELEMETRY_PROVIDER_CONFIG: _telemetryProviderConfig,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Was the rename of the telemetryProvider and telemetryProviderConfig variables intentional?

Copy link
Author

Choose a reason for hiding this comment

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

Accident after linting + merge. Will revert

| 'tableau:insights:read'
| 'tableau:views:download'
| 'tableau:insight_brief:create';
type JwtScopes = TableauApiScope;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's kill the JwtScopes type entirely.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants