From 77604b219f95c1e906c238c57d76c9a65f73ef6f Mon Sep 17 00:00:00 2001 From: Matt Filbert Date: Wed, 21 Jan 2026 10:15:13 -0600 Subject: [PATCH 01/20] @W-20976712: Add MCP OAuth scope support and enforcement Co-authored-by: Cursor --- OAUTH_IMPLEMENTATION_PLAN.md | 315 ++++++++++++++++++ docs/docs/configuration/mcp-config/oauth.md | 82 ++++- env.example.list | 5 +- src/config.ts | 30 ++ src/server/middleware.ts | 2 +- .../.well-known/oauth-authorization-server.ts | 12 +- src/server/oauth/authMiddleware.ts | 43 ++- src/server/oauth/authorize.ts | 44 ++- src/server/oauth/callback.ts | 1 + src/server/oauth/provider.ts | 2 +- src/server/oauth/schemas.ts | 6 +- src/server/oauth/scopes.ts | 143 ++++++++ src/server/oauth/token.ts | 40 +++ src/server/oauth/types.ts | 2 + types/process-env.d.ts | 3 + 15 files changed, 712 insertions(+), 18 deletions(-) create mode 100644 OAUTH_IMPLEMENTATION_PLAN.md create mode 100644 src/server/oauth/scopes.ts diff --git a/OAUTH_IMPLEMENTATION_PLAN.md b/OAUTH_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..cec4a9ab --- /dev/null +++ b/OAUTH_IMPLEMENTATION_PLAN.md @@ -0,0 +1,315 @@ +# Tableau MCP OAuth Integration Implementation Plan + +## Executive Summary + +This document outlines the phased approach to integrate Tableau OAuth with the MCP server, implementing MCP authorization best practices, scope management, and token exchange mechanisms. The work is divided into three phases, with Phase 1 focusing on immediate improvements we can make independently while coordinating with the auth team on Phase 2 and 3 requirements. + +--- + +## Current State Analysis + +### Existing OAuth Implementation +- ✅ Basic OAuth 2.1 flow with PKCE implemented +- ✅ Authorization server metadata endpoint (`.well-known/oauth-authorization-server`) +- ✅ Protected resource metadata endpoint (`.well-known/oauth-protected-resource`) +- ✅ Client ID Metadata Documents support +- ✅ Dynamic client registration support +- ✅ Token endpoint with authorization_code, refresh_token, and client_credentials grants +- ✅ JWE-encrypted access tokens + +### Gaps Identified +- ❌ **No scope support**: `scopes_supported: []` in authorization server metadata +- ❌ **No scope parameter** in `WWW-Authenticate` header (per MCP spec recommendation) +- ❌ **No scope validation** in authorization or token endpoints +- ❌ **No scope-to-Tableau mapping** mechanism +- ❌ **Token exchange** needs to be adapted for Tableau REST API compatibility +- ❌ **Scope challenge handling** not implemented (for insufficient scope errors) +- ❌ **Step-up authorization flow** not implemented (for requesting additional scopes) +- ⚠️ **Testing gaps**: Long-lived token testing (30-day refresh tokens), scope edge cases + +--- + +## Phase 1: Best Practices & Auth Uplift (Independent Work) + +**Timeline**: 2-3 weeks +**Dependencies**: None (can start immediately) + +### 1.1 Review MCP Authorization Specification Changes +- [ ] Review latest MCP spec (2025-11-25) vs current implementation (2025-06-18) +- [ ] Document any breaking changes or new requirements +- [ ] Update code references to latest spec version +- [ ] Review Security Best Practices guide: https://modelcontextprotocol.io/docs/tutorials/security/authorization + +### 1.2 Implement Scope Infrastructure +**Files to modify:** +- `src/server/oauth/.well-known/oauth-authorization-server.ts` +- `src/server/oauth/.well-known/oauth-protected-resource.ts` +- `src/server/oauth/authMiddleware.ts` +- `src/server/oauth/authorize.ts` +- `src/server/oauth/schemas.ts` +- `src/server/oauth/token.ts` + +**Tasks:** +- [ ] Add scope configuration to config (environment variable or config file) +- [ ] Define scope data structures and types +- [ ] Implement `scopes_supported` in authorization server metadata +- [ ] Add scope parameter parsing in authorize endpoint +- [ ] Add scope validation logic +- [ ] Store requested scopes in pending authorization and authorization codes +- [ ] Include scopes in issued access tokens (JWE payload) +- [ ] Add scope extraction in auth middleware + +### 1.3 Implement WWW-Authenticate Scope Guidance +**Per MCP Spec Section: Protected Resource Metadata Discovery Requirements** + +- [ ] Add `scope` parameter to `WWW-Authenticate` header in `authMiddleware.ts` +- [ ] Determine appropriate scopes for each MCP endpoint/tool +- [ ] Implement scope selection strategy (per MCP spec) +- [ ] Handle scope challenges in 401 responses + +### 1.4 Scope Research & Documentation +- [ ] Research Tableau OAuth scope format and structure +- [ ] Document scope naming conventions +- [ ] Create scope inventory: what scopes does Tableau OAuth support? +- [ ] Document scope lifecycle: adding/removing scopes without breaking existing clients +- [ ] Design scope versioning strategy (if needed) + +### 1.5 Code Quality Improvements +- [ ] Review and improve error handling in OAuth flows +- [ ] Add comprehensive logging for OAuth operations +- [ ] Improve token validation error messages +- [ ] Add input validation for scope parameters +- [ ] Review security best practices implementation: + - [ ] Token audience validation (already implemented via `AUDIENCE`) + - [ ] PKCE enforcement (already implemented) + - [ ] Redirect URI validation (already implemented) + - [ ] SSRF protection (already implemented in client metadata fetching) + +### 1.6 Testing Infrastructure Updates +- [ ] Update OAuth test suite to handle scopes +- [ ] Add tests for scope validation +- [ ] Add tests for WWW-Authenticate scope parameter +- [ ] Document testing approach for long-lived tokens (30-day refresh tokens) +- [ ] Create test utilities for simulating token expiration scenarios + +--- + +## Phase 2: Scope Mapping & Token Exchange (Coordination with Auth Team) + +**Timeline**: 3-4 weeks +**Dependencies**: Auth team decisions on scope mapping and token exchange mechanism + +### 2.1 Scope Mapping Design & Implementation +**Coordination needed with George:** +- [ ] Finalize scope mapping strategy (MCP scopes → Tableau scopes) +- [ ] Define scope mapping configuration format +- [ ] Implement scope mapping logic +- [ ] Handle scope translation in authorization flow +- [ ] Handle scope translation in token exchange + +**Implementation tasks:** +- [ ] Create scope mapping module/utility +- [ ] Add scope mapping configuration to config +- [ ] Integrate scope mapping into authorize endpoint (when forwarding to Tableau OAuth) +- [ ] Integrate scope mapping into token exchange + +### 2.2 Token Exchange Implementation +**Coordination needed with Auth team:** +- [ ] Understand Tableau OAuth token format and requirements +- [ ] Determine token exchange endpoint and mechanism +- [ ] Understand how to convert MCP tokens to Tableau REST API-compatible tokens +- [ ] Define token refresh strategy + +**Implementation tasks:** +- [ ] Implement token exchange logic +- [ ] Update token endpoint to handle Tableau token exchange +- [ ] Ensure exchanged tokens work with Tableau REST APIs +- [ ] Implement token refresh flow for exchanged tokens +- [ ] Add error handling for token exchange failures + +### 2.3 Authorization Flow Updates +- [ ] Update authorize endpoint to include mapped scopes in Tableau OAuth redirect +- [ ] Handle scope parameter from client requests +- [ ] Validate requested scopes against supported scopes +- [ ] Store scope information throughout the flow + +### 2.4 Testing +- [ ] Integration tests with Tableau OAuth +- [ ] Test scope mapping in various scenarios +- [ ] Test token exchange end-to-end +- [ ] Test token refresh with exchanged tokens +- [ ] Test with actual Tableau REST API calls + +--- + +## Phase 3: Server Support & Full Cloud Integration + +**Timeline**: 2-3 weeks +**Dependencies**: Phase 2 completion, Server vs Cloud requirements clarification + +### 3.1 Server vs Cloud Differentiation +**Decisions needed:** +- [ ] Determine if Server needs scope support (or can skip for beta) +- [ ] Define configuration knobs/environment variables for Server vs Cloud behavior +- [ ] Determine if Server OAuth flow differs from Cloud + +**Implementation tasks:** +- [ ] Add environment variable/config option for Server mode +- [ ] Implement conditional scope handling (skip scopes in Server mode if needed) +- [ ] Ensure Server OAuth flow still works without breaking changes +- [ ] Add Server-specific configuration options + +### 3.2 Full Cloud Integration +- [ ] Complete integration with Tableau Cloud OAuth +- [ ] Test end-to-end flow with Tableau Cloud +- [ ] Verify token exchange works with Cloud +- [ ] Verify scope mapping works with Cloud +- [ ] Performance testing with Cloud + +### 3.3 Edge Cases & Error Handling +**Coordination with Andy:** +- [ ] Review edge cases identified +- [ ] Implement handling for: + - [ ] Insufficient scope errors (scope challenge) + - [ ] Step-up authorization (requesting additional scopes) + - [ ] Token expiration during long-running operations + - [ ] Network failures during token exchange + - [ ] Invalid scope requests + - [ ] Scope revocation scenarios + +### 3.4 Testing & Validation +- [ ] End-to-end testing with both Server and Cloud +- [ ] Test scope addition/removal scenarios +- [ ] Test backward compatibility (clients without scope support) +- [ ] Load testing with token refresh +- [ ] Security testing (token validation, scope validation) + +--- + +## Technical Implementation Details + +### Scope Data Structure +```typescript +// Proposed scope structure +type McpScope = + | 'tableau:content:read' + | 'tableau:content:write' + | 'tableau:datasource:query' + | 'tableau:workbook:read' + | 'tableau:view:read' + | 'tableau:view:download' + // ... additional scopes as needed + +interface ScopeMapping { + mcpScope: McpScope; + tableauScopes: string[]; // Array of Tableau OAuth scopes +} +``` + +### WWW-Authenticate Header Enhancement +```typescript +// Current (line 51 in authMiddleware.ts): +`Bearer realm="MCP", resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"` + +// Enhanced (per MCP spec): +`Bearer realm="MCP", resource_metadata="${baseUrl}/.well-known/oauth-protected-resource", scope="tableau:content:read tableau:datasource:query"` +``` + +### Configuration Additions +```typescript +// Add to config.ts +oauth: { + // ... existing config + scopesSupported: string[]; // MCP scopes we support + scopeMappings: ScopeMapping[]; // MCP → Tableau scope mappings + serverMode?: boolean; // If true, may skip scope requirements +} +``` + +--- + +## Testing Strategy + +### Unit Tests +- Scope validation logic +- Scope mapping logic +- Token exchange logic +- WWW-Authenticate header generation + +### Integration Tests +- Full OAuth flow with scopes +- Token exchange with Tableau OAuth +- Scope challenge handling +- Step-up authorization + +### E2E Tests +- Complete flow with MCP client +- Token refresh scenarios +- Long-lived token testing (30-day refresh tokens) +- Server vs Cloud behavior + +### Test Utilities Needed +- Mock Tableau OAuth server with scope support +- Token expiration simulation +- Scope challenge simulation +- Long-running operation simulation + +--- + +## Open Questions for Auth Team + +1. **Scope Format**: What is the exact format of Tableau OAuth scopes? (e.g., `tableau:content:read`, `tableau.content.read`, etc.) + +2. **Scope Mapping**: How should we map MCP scopes to Tableau scopes? One-to-one, one-to-many, or many-to-one? + +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? + +4. **Scope Lifecycle**: + - How do we add new scopes without breaking existing clients? + - How do we handle scope deprecation? + - Can scopes be requested dynamically, or must they be pre-registered? + +5. **Server vs Cloud**: + - Does Server OAuth support the same scope mechanism as Cloud? + - Should Server skip scope requirements for beta? + +6. **Token Lifetime**: + - What are the actual token lifetimes (access, refresh)? + - How should we test 30-day refresh tokens? + +--- + +## Success Criteria + +### Phase 1 +- ✅ MCP authorization best practices implemented +- ✅ Scope infrastructure in place +- ✅ WWW-Authenticate scope guidance implemented +- ✅ Comprehensive scope research documented +- ✅ Code quality improvements completed + +### Phase 2 +- ✅ Scope mapping implemented and tested +- ✅ Token exchange working with Tableau OAuth +- ✅ Full authorization flow with scopes working end-to-end + +### Phase 3 +- ✅ Server and Cloud both supported +- ✅ All edge cases handled +- ✅ Comprehensive test coverage +- ✅ Production-ready implementation + +--- + +## References + +- [MCP Authorization Specification (2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) +- [MCP Security Best Practices](https://modelcontextprotocol.io/docs/tutorials/security/authorization) +- [OAuth 2.1 IETF Draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13) +- [OAuth 2.0 Protected Resource Metadata (RFC9728)](https://www.rfc-editor.org/rfc/rfc9728) +- [OAuth 2.0 Authorization Server Metadata (RFC8414)](https://www.rfc-editor.org/rfc/rfc8414) + + diff --git a/docs/docs/configuration/mcp-config/oauth.md b/docs/docs/configuration/mcp-config/oauth.md index 7abe33f5..f6a053b1 100644 --- a/docs/docs/configuration/mcp-config/oauth.md +++ b/docs/docs/configuration/mcp-config/oauth.md @@ -14,15 +14,19 @@ is accessed using a local development URL e.g. `http://127.0.0.1:3927/tableau-mc ## How to Enable OAuth -To enable OAuth, set the [`OAUTH_ISSUER`](#oauth_issuer) environment variable to the origin of your MCP server. When a URL for `OAUTH_ISSUER` is provided, the MCP server will act as an OAuth 2.1 resource server, capable of accepting and responding to protected resource requests using encrypted access tokens. +To enable OAuth, set the [`OAUTH_ISSUER`](#oauth_issuer) environment variable to the origin of your +MCP server. When a URL for `OAUTH_ISSUER` is provided, the MCP server will act as an OAuth 2.1 +resource server, capable of accepting and responding to protected resource requests using encrypted +access tokens. When OAuth is enabled: -- MCP clients will be required to authenticate via Tableau OAuth before connecting to the MCP server +- MCP clients will be required to authenticate via Tableau OAuth before connecting to the MCP + server - The [`TRANSPORT`](#transport) will default to `http` (required for OAuth) - The [`AUTH`](#auth) method will default to `oauth` For more information, please see the -[MCP Authorization spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization). +[MCP Authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
@@ -168,6 +172,78 @@ Where `Y2xpZW50SWQ6c2VjcmV0` is the base64 encoding of `clientId:secret`.
+### `OAUTH_SCOPES_SUPPORTED` + +A space- or comma-separated list of scopes supported by the MCP server. + +- Optional, but recommended when OAuth is enabled. +- Used to populate `scopes_supported` in the OAuth metadata. +- Example: `tableau:content:read tableau:view:read tableau:datasource:query` + +
+ +## Recommended scope set (beta) + +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. + +Suggested initial scopes: + +- `tableau:content:read` +- `tableau:content:write` +- `tableau:datasource:query` +- `tableau:datasource:read` +- `tableau:workbook:read` +- `tableau:workbook:write` +- `tableau:view:read` +- `tableau:view:download` +- `tableau:project:read` +- `tableau:metrics:read` +- `tableau:insights:read` + +Example configuration: + +``` +OAUTH_SCOPES_SUPPORTED=tableau:content:read tableau:content:write tableau:datasource:query tableau:datasource:read tableau:workbook:read tableau:workbook:write tableau:view:read tableau:view:download tableau:project:read tableau:metrics:read tableau:insights:read +OAUTH_REQUIRED_SCOPES=tableau:content:read tableau:content:write tableau:datasource:query tableau:datasource:read tableau:workbook:read tableau:workbook:write tableau:view:read tableau:view:download tableau:project:read tableau:metrics:read tableau:insights:read +``` + +
+ +## Why MCP scopes (in addition to Tableau API scopes) + +Tableau API scopes alone are not sufficient to protect all MCP functionality. + +- 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. + +
+ +### `OAUTH_REQUIRED_SCOPES` + +A space- or comma-separated list of scopes required to access the MCP server. + +- Optional. If not set, all values from `OAUTH_SCOPES_SUPPORTED` are required. +- The MCP server will include these in `WWW-Authenticate` challenges and enforce them on requests. +- Example: `tableau:content:read tableau:view:read` + +
+ +### `OAUTH_DISABLE_SCOPES` + +Disable scope enforcement and scope challenges. + +- Default: `false` +- Useful for Tableau Server deployments that do not want to enforce scopes yet. +- When `true`, the MCP server will not include scopes in `WWW-Authenticate` challenges and will not + enforce scopes on incoming access tokens. + +
+ ### `OAUTH_JWE_PRIVATE_KEY` The RSA private key used to decrypt the OAuth access token. diff --git a/env.example.list b/env.example.list index 497feff9..00a072b7 100644 --- a/env.example.list +++ b/env.example.list @@ -9,4 +9,7 @@ INCLUDE_TOOLS= EXCLUDE_TOOLS= MAX_RESULT_LIMIT= DISABLE_QUERY_DATASOURCE_VALIDATION_REQUESTS= -DISABLE_METADATA_API_REQUESTS= \ No newline at end of file +DISABLE_METADATA_API_REQUESTS= +OAUTH_SCOPES_SUPPORTED= +OAUTH_REQUIRED_SCOPES= +OAUTH_DISABLE_SCOPES= \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index 7e15205e..1e737d3d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -81,6 +81,9 @@ export class Config { refreshTokenTimeoutMs: number; clientIdSecretPairs: Record | null; dnsServers: string[]; + scopesSupported: string[]; + requiredScopes: string[]; + enforceScopes: boolean; }; telemetry: TelemetryConfig; productTelemetryEndpoint: string; @@ -146,6 +149,9 @@ export class Config { OAUTH_AUTHORIZATION_CODE_TIMEOUT_MS: authzCodeTimeoutMs, OAUTH_ACCESS_TOKEN_TIMEOUT_MS: accessTokenTimeoutMs, OAUTH_REFRESH_TOKEN_TIMEOUT_MS: refreshTokenTimeoutMs, + OAUTH_SCOPES_SUPPORTED: oauthScopesSupported, + OAUTH_REQUIRED_SCOPES: oauthRequiredScopes, + OAUTH_DISABLE_SCOPES: oauthDisableScopes, TELEMETRY_PROVIDER: telemetryProvider, TELEMETRY_PROVIDER_CONFIG: telemetryProviderConfig, PRODUCT_TELEMETRY_ENDPOINT: productTelemetryEndpoint, @@ -215,6 +221,16 @@ export class Config { ); const disableOauthOverride = disableOauth === 'true'; + const scopesSupported = parseScopes(oauthScopesSupported); + const requiredScopes = parseScopes(oauthRequiredScopes); + const disableScopes = oauthDisableScopes === 'true'; + const effectiveRequiredScopes = disableScopes + ? [] + : requiredScopes.length > 0 + ? requiredScopes + : scopesSupported; + const enforceScopes = !disableScopes && effectiveRequiredScopes.length > 0; + this.oauth = { enabled: disableOauthOverride ? false : !!oauthIssuer, issuer: oauthIssuer ?? '', @@ -250,6 +266,9 @@ export class Config { return acc; }, {}) : null, + scopesSupported, + requiredScopes: effectiveRequiredScopes, + enforceScopes, }; const parsedProvider = isTelemetryProvider(telemetryProvider) ? telemetryProvider : 'noop'; @@ -465,6 +484,17 @@ function getCorsOriginConfig(corsOriginConfig: string): CorsOptions['origin'] { } } +function parseScopes(value: string | undefined): string[] { + if (!value) { + return []; + } + + return value + .split(/[,\s]+/) + .map((scope) => scope.trim()) + .filter((scope) => scope.length > 0); +} + function getTrustProxyConfig(trustProxyConfig: string): boolean | number | string | null { if (!trustProxyConfig) { return null; diff --git a/src/server/middleware.ts b/src/server/middleware.ts index e8650133..b9a871c3 100644 --- a/src/server/middleware.ts +++ b/src/server/middleware.ts @@ -14,7 +14,7 @@ export function validateProtocolVersion(req: Request, res: Response, next: NextF } // Check supported versions - const supportedVersions = ['2025-06-18', '2025-03-26', '2024-11-05']; + const supportedVersions = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05']; if (!supportedVersions.includes(version as string)) { res.status(400).json({ jsonrpc: '2.0', diff --git a/src/server/oauth/.well-known/oauth-authorization-server.ts b/src/server/oauth/.well-known/oauth-authorization-server.ts index 97b9e7b9..c60af8ac 100644 --- a/src/server/oauth/.well-known/oauth-authorization-server.ts +++ b/src/server/oauth/.well-known/oauth-authorization-server.ts @@ -10,16 +10,16 @@ import { getConfig } from '../../../config.js'; */ export function oauthAuthorizationServer(app: express.Application): void { app.get('/.well-known/oauth-authorization-server', (_req, res) => { - const origin = getConfig().oauth.issuer; + const { issuer, scopesSupported } = getConfig().oauth; res.json({ - issuer: origin, - authorization_endpoint: `${origin}/oauth/authorize`, - token_endpoint: `${origin}/oauth/token`, - registration_endpoint: `${origin}/oauth/register`, + issuer, + authorization_endpoint: `${issuer}/oauth/authorize`, + token_endpoint: `${issuer}/oauth/token`, + registration_endpoint: `${issuer}/oauth/register`, response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'], code_challenge_methods_supported: ['S256'], - scopes_supported: [], + scopes_supported: scopesSupported, token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'], subject_types_supported: ['public'], client_id_metadata_document_supported: true, diff --git a/src/server/oauth/authMiddleware.ts b/src/server/oauth/authMiddleware.ts index c8fd7200..bf133fa5 100644 --- a/src/server/oauth/authMiddleware.ts +++ b/src/server/oauth/authMiddleware.ts @@ -8,6 +8,7 @@ import { fromError } from 'zod-validation-error'; import { getConfig } from '../../config.js'; import { AUDIENCE } from './provider.js'; import { mcpAccessTokenSchema, mcpAccessTokenUserOnlySchema, TableauAuthInfo } from './schemas.js'; +import { formatScopes, parseScopes } from './scopes.js'; import { AuthenticatedRequest } from './types.js'; /** @@ -44,11 +45,14 @@ export function authMiddleware(privateKey: KeyObject): RequestHandler { } const baseUrl = `${req.protocol}://${req.get('host')}`; + const { enforceScopes, requiredScopes } = getConfig().oauth; + const scopeParam = + enforceScopes && requiredScopes.length > 0 ? `, scope="${formatScopes(requiredScopes)}"` : ''; res .status(401) .header( 'WWW-Authenticate', - `Bearer realm="MCP", resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"`, + `Bearer realm="MCP", resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"${scopeParam}`, ) .json({ error: 'unauthorized', @@ -80,7 +84,39 @@ export function authMiddleware(privateKey: KeyObject): RequestHandler { }); return; } - req.auth = result.value; + const authInfo = result.value; + const { enforceScopes, requiredScopes } = getConfig().oauth; + if (enforceScopes && requiredScopes.length > 0) { + const missingRequiredScopes = requiredScopes.filter((scope) => !authInfo.scopes.includes(scope)); + if (missingRequiredScopes.length > 0) { + const baseUrl = `${req.protocol}://${req.get('host')}`; + const scopeParam = `scope="${formatScopes(requiredScopes)}"`; + const wwwAuthenticate = `Bearer realm="MCP", error="insufficient_scope", error_description="Missing required scopes", resource_metadata="${baseUrl}/.well-known/oauth-protected-resource", ${scopeParam}`; + + if (req.method === 'GET' && req.headers.accept?.includes('text/event-stream')) { + res.writeHead(403, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'WWW-Authenticate': wwwAuthenticate, + }); + res.write('event: error\n'); + res.write( + `data: {"error": "insufficient_scope", "error_description": "Missing required scopes"}\n\n`, + ); + res.end(); + return; + } + + res.status(403).header('WWW-Authenticate', wwwAuthenticate).json({ + error: 'insufficient_scope', + error_description: 'Missing required scopes', + }); + return; + } + } + + req.auth = authInfo; next(); }; } @@ -118,6 +154,7 @@ async function verifyAccessToken( return new Err('Invalid or expired access token'); } + const tokenScopes = parseScopes(mcpAccessToken.data.scope); let tableauAuthInfo: TableauAuthInfo; if (config.auth === 'oauth') { const mcpAccessToken = mcpAccessTokenSchema.safeParse(payload); @@ -157,7 +194,7 @@ async function verifyAccessToken( return Ok({ token, clientId, - scopes: [], + scopes: tokenScopes, expiresAt: payload.exp, extra: tableauAuthInfo, }); diff --git a/src/server/oauth/authorize.ts b/src/server/oauth/authorize.ts index f133b2b0..2a656d4d 100644 --- a/src/server/oauth/authorize.ts +++ b/src/server/oauth/authorize.ts @@ -16,6 +16,7 @@ import { generateCodeChallenge } from './generateCodeChallenge.js'; import { isValidRedirectUri } from './isValidRedirectUri.js'; import { TABLEAU_CLOUD_SERVER_URL } from './provider.js'; import { cimdMetadataSchema, ClientMetadata, mcpAuthorizeSchema } from './schemas.js'; +import { formatScopes, parseScopes, validateScopes } from './scopes.js'; import { PendingAuthorization } from './types.js'; /** @@ -42,8 +43,15 @@ export function authorize( return; } - const { client_id, redirect_uri, response_type, code_challenge, code_challenge_method, state } = - result.data; + const { + client_id, + redirect_uri, + response_type, + code_challenge, + code_challenge_method, + state, + scope, + } = result.data; const clientIdUrl = parseUrl(client_id); if (clientIdUrl) { @@ -97,6 +105,34 @@ export function authorize( return; } + const { enforceScopes, requiredScopes, scopesSupported } = config.oauth; + const requestedScopes = parseScopes(scope); + const { valid: validScopes, invalid: invalidScopes } = validateScopes( + requestedScopes, + scopesSupported, + ); + + if (invalidScopes.length > 0) { + res.status(400).json({ + error: 'invalid_scope', + error_description: `Unsupported scopes: ${invalidScopes.join(', ')}`, + }); + return; + } + + const scopesToGrant = + enforceScopes && validScopes.length === 0 ? requiredScopes : validScopes; + if (enforceScopes) { + const missingRequiredScopes = requiredScopes.filter((s) => !scopesToGrant.includes(s)); + if (missingRequiredScopes.length > 0) { + res.status(400).json({ + error: 'invalid_scope', + error_description: `Missing required scopes: ${missingRequiredScopes.join(', ')}`, + }); + return; + } + } + // Generate Tableau state and store pending authorization const tableauState = randomBytes(32).toString('hex'); const authKey = randomBytes(32).toString('hex'); @@ -114,6 +150,7 @@ export function authorize( tableauState, tableauClientId, tableauCodeVerifier, + scopes: scopesToGrant, }); // Clean up expired authorizations @@ -132,6 +169,9 @@ export function authorize( oauthUrl.searchParams.set('target_site', config.siteName); oauthUrl.searchParams.set('device_name', getDeviceName(redirect_uri, state ?? '')); oauthUrl.searchParams.set('client_type', 'tableau-mcp'); + if (scopesToGrant.length > 0) { + oauthUrl.searchParams.set('scope', formatScopes(scopesToGrant)); + } 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. diff --git a/src/server/oauth/callback.ts b/src/server/oauth/callback.ts index b37d3e4b..27365fe8 100644 --- a/src/server/oauth/callback.ts +++ b/src/server/oauth/callback.ts @@ -150,6 +150,7 @@ export function callback( user: sessionResult.value.user, server, tableauClientId: pendingAuth.tableauClientId, + scopes: pendingAuth.scopes, tokens: { accessToken, refreshToken, diff --git a/src/server/oauth/provider.ts b/src/server/oauth/provider.ts index bb9c56cf..5c604d9d 100644 --- a/src/server/oauth/provider.ts +++ b/src/server/oauth/provider.ts @@ -19,7 +19,7 @@ export const AUDIENCE = 'tableau-mcp-server'; * OAuth 2.1 Provider * * Implements the complete MCP OAuth 2.1 flow with PKCE - * @see https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization + * @see https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization * */ export class OAuthProvider { diff --git a/src/server/oauth/schemas.ts b/src/server/oauth/schemas.ts index ff56cc42..b7d2141f 100644 --- a/src/server/oauth/schemas.ts +++ b/src/server/oauth/schemas.ts @@ -9,6 +9,7 @@ export const mcpAuthorizeSchema = z.object({ code_challenge: requiredString('code_challenge'), code_challenge_method: requiredString('code_challenge_method'), state: z.string().optional(), + scope: z.string().optional(), }); export const mcpTokenSchema = z @@ -43,13 +44,15 @@ export const mcpTokenSchema = z // Optional because client/secret pair may be provided in the request body instead of the query string client_id: z.string().optional(), client_secret: z.string().optional(), + scope: z.string().optional(), }), ) .transform((data) => { - const { client_id, client_secret } = data; + const { client_id, client_secret, scope } = data; const clientIdSecretPair = { clientId: client_id, clientSecret: client_secret, + scope, }; if (data.grant_type === 'authorization_code') { @@ -91,6 +94,7 @@ export const mcpAccessTokenUserOnlySchema = z.object({ tableauServer: requiredString('tableauServer'), // Optional because there may not be a user associated with the access token, e.g. for client credentials grant type tableauUserId: z.string().optional(), + scope: z.string().optional(), }); export const mcpAccessTokenSchema = mcpAccessTokenUserOnlySchema.extend({ diff --git a/src/server/oauth/scopes.ts b/src/server/oauth/scopes.ts new file mode 100644 index 00000000..55de9e9a --- /dev/null +++ b/src/server/oauth/scopes.ts @@ -0,0 +1,143 @@ +/** + * OAuth Scope Definitions and Utilities + * + * Defines MCP scopes for Tableau MCP server and provides utilities + * for scope validation and management. + */ + +import { ToolName } from '../../tools/toolName.js'; + +/** + * MCP Scopes supported by the Tableau MCP server + * + * These scopes represent permissions that clients can request + * when authenticating with the MCP server. + */ +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'; + +/** + * Default scopes supported by the MCP server + * + * This list can be configured via environment variable or config file. + */ +export const DEFAULT_SCOPES_SUPPORTED: 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', +]; + +/** + * Minimal default scopes suggested when no specific tool is known. + */ +export const DEFAULT_REQUIRED_SCOPES: McpScope[] = ['tableau:content:read']; + +/** + * 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); +} + +/** + * Parses a space-separated scope string into an array of scopes + * + * @param scopeString - Space-separated scope string (e.g., "read write") + * @returns Array of valid scope strings + */ +export function parseScopes(scopeString: string | undefined): string[] { + if (!scopeString || scopeString.trim() === '') { + return []; + } + const scopes = scopeString + .split(/\s+/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + return [...new Set(scopes)]; +} + +/** + * Validates an array of scopes against the supported scopes + * + * @param requestedScopes - Array of scope strings to validate + * @param supportedScopes - Array of supported scope strings + * @returns Object with valid and invalid scopes + */ +export function validateScopes( + requestedScopes: string[], + supportedScopes: string[], +): { valid: string[]; invalid: string[] } { + const valid: string[] = []; + const invalid: string[] = []; + + for (const scope of requestedScopes) { + if (supportedScopes.includes(scope)) { + valid.push(scope); + } else { + invalid.push(scope); + } + } + + return { valid, invalid }; +} + +/** + * Determines the required scopes for a given MCP endpoint/tool + * + * This function maps MCP tools to their required scopes. + * This is used to provide scope guidance in WWW-Authenticate headers. + * + * @param endpoint - The MCP endpoint or tool name + * @returns Array of required scopes for the endpoint + */ +export function getRequiredScopesForTool(toolName: ToolName | string): string[] { + const toolScopeMap: Record = { + 'list-datasources': ['tableau:content:read'], + 'list-workbooks': ['tableau:content:read'], + 'list-views': ['tableau:content:read'], + 'query-datasource': ['tableau:viz_data_service:read'], + 'get-datasource-metadata': ['tableau:content:read', 'tableau:viz_data_service:read'], + 'get-workbook': ['tableau:content:read'], + 'get-view-data': ['tableau:views:download'], + 'get-view-image': ['tableau:views:download'], + 'list-all-pulse-metric-definitions': ['tableau:insight_definitions_metrics:read'], + 'list-pulse-metric-definitions-from-definition-ids': ['tableau:insight_definitions_metrics:read'], + 'list-pulse-metrics-from-metric-definition-id': ['tableau:insight_metrics:read'], + 'list-pulse-metrics-from-metric-ids': ['tableau:insight_metrics:read'], + 'list-pulse-metric-subscriptions': ['tableau:metric_subscriptions:read'], + 'generate-pulse-metric-value-insight-bundle': ['tableau:insights:read'], + 'generate-pulse-insight-brief': ['tableau:insight_brief:create'], + 'search-content': ['tableau:content:read'], + }; + + if (toolName in toolScopeMap) { + return toolScopeMap[toolName as ToolName]; + } + + return DEFAULT_REQUIRED_SCOPES; +} + +/** + * Formats scopes as a space-separated string (RFC 6749 format) + * + * @param scopes - Array of scope strings + * @returns Space-separated scope string + */ +export function formatScopes(scopes: string[]): string { + return scopes.join(' '); +} + + diff --git a/src/server/oauth/token.ts b/src/server/oauth/token.ts index 45c13237..36b2a9bf 100644 --- a/src/server/oauth/token.ts +++ b/src/server/oauth/token.ts @@ -11,6 +11,7 @@ import { setLongTimeout } from '../../utils/setLongTimeout.js'; import { generateCodeChallenge } from './generateCodeChallenge.js'; import { AUDIENCE } from './provider.js'; import { mcpTokenSchema } from './schemas.js'; +import { formatScopes, parseScopes, validateScopes } from './scopes.js'; import { AuthorizationCode, ClientCredentials, RefreshTokenData, UserAndTokens } from './types.js'; /** @@ -88,6 +89,7 @@ export function token( server: authCode.server, clientId: authCode.clientId, tokens: authCode.tokens, + scopes: authCode.scopes, expiresAt: Math.floor((Date.now() + config.oauth.refreshTokenTimeoutMs) / 1000), tableauClientId: authCode.tableauClientId, }); @@ -104,10 +106,39 @@ export function token( token_type: 'Bearer', expires_in: config.oauth.accessTokenTimeoutMs / 1000, refresh_token: refreshTokenId, + scope: formatScopes(authCode.scopes), }); return; } case 'client_credentials': { + const { enforceScopes, requiredScopes, scopesSupported } = config.oauth; + const requestedScopes = parseScopes(result.data.scope); + const { valid: validScopes, invalid: invalidScopes } = validateScopes( + requestedScopes, + scopesSupported, + ); + + if (invalidScopes.length > 0) { + res.status(400).json({ + error: 'invalid_scope', + error_description: `Unsupported scopes: ${invalidScopes.join(', ')}`, + }); + return; + } + + const scopesToGrant = + enforceScopes && validScopes.length === 0 ? requiredScopes : validScopes; + if (enforceScopes) { + const missingRequiredScopes = requiredScopes.filter((s) => !scopesToGrant.includes(s)); + if (missingRequiredScopes.length > 0) { + res.status(400).json({ + error: 'invalid_scope', + error_description: `Missing required scopes: ${missingRequiredScopes.join(', ')}`, + }); + return; + } + } + // Generate access token for client credentials grant type. // Refresh token is not supported for client credentials grant type. // https://www.rfc-editor.org/rfc/rfc6749#section-4.4.3 @@ -116,6 +147,7 @@ export function token( clientId: clientCredentialsResult.value.clientId, server: config.server, }, + scopesToGrant, publicKey, ); @@ -123,6 +155,7 @@ export function token( access_token: accessToken, token_type: 'Bearer', expires_in: config.oauth.accessTokenTimeoutMs / 1000, + scope: formatScopes(scopesToGrant), }); return; } @@ -158,6 +191,7 @@ export function token( clientId: tokenData.clientId, server: tokenData.server, tokens: tokenData.tokens, + scopes: tokenData.scopes, }, publicKey, ); @@ -178,6 +212,7 @@ export function token( refreshToken: newTableauRefreshToken, expiresInSeconds, }, + scopes: tokenData.scopes, }, publicKey, ); @@ -192,6 +227,7 @@ export function token( server: tokenData.server, clientId: tokenData.clientId, tokens: tokenData.tokens, + scopes: tokenData.scopes, expiresAt: Math.floor((Date.now() + config.oauth.refreshTokenTimeoutMs) / 1000), tableauClientId: tokenData.tableauClientId, }); @@ -201,6 +237,7 @@ export function token( token_type: 'Bearer', expires_in: config.oauth.accessTokenTimeoutMs / 1000, refresh_token: refreshTokenId, + scope: formatScopes(tokenData.scopes), }); return; } @@ -235,6 +272,7 @@ async function createAccessToken(tokenData: UserAndTokens, publicKey: KeyObject) exp: Math.floor((Date.now() + config.oauth.accessTokenTimeoutMs) / 1000), aud: AUDIENCE, iss: config.oauth.issuer, + scope: formatScopes(tokenData.scopes), ...(config.auth === 'oauth' ? { tableauAccessToken: tokenData.tokens.accessToken, @@ -254,6 +292,7 @@ async function createAccessToken(tokenData: UserAndTokens, publicKey: KeyObject) async function createClientCredentialsAccessToken( clientCredentials: ClientCredentials, + scopes: string[], publicKey: KeyObject, ): Promise { const config = getConfig(); @@ -265,6 +304,7 @@ async function createClientCredentialsAccessToken( exp: Math.floor((Date.now() + config.oauth.accessTokenTimeoutMs) / 1000), aud: AUDIENCE, iss: config.oauth.issuer, + scope: formatScopes(scopes), }); const jwe = await new CompactEncrypt(new TextEncoder().encode(payload)) diff --git a/src/server/oauth/types.ts b/src/server/oauth/types.ts index 8420c7cd..99a24906 100644 --- a/src/server/oauth/types.ts +++ b/src/server/oauth/types.ts @@ -21,6 +21,7 @@ export type PendingAuthorization = { tableauState: string; tableauClientId: string; tableauCodeVerifier: string; + scopes: string[]; }; export type ClientCredentials = { @@ -33,6 +34,7 @@ export type UserAndTokens = { clientId: string; server: string; tokens: Tokens; + scopes: string[]; }; export type AuthorizationCode = UserAndTokens & { diff --git a/types/process-env.d.ts b/types/process-env.d.ts index 213c8cce..311792cd 100644 --- a/types/process-env.d.ts +++ b/types/process-env.d.ts @@ -49,6 +49,9 @@ export interface ProcessEnvEx { OAUTH_REDIRECT_URI: string | undefined; OAUTH_LOCK_SITE: string | undefined; OAUTH_CLIENT_ID_SECRET_PAIRS: string | undefined; + OAUTH_SCOPES_SUPPORTED: string | undefined; + OAUTH_REQUIRED_SCOPES: string | undefined; + OAUTH_DISABLE_SCOPES: string | undefined; OAUTH_AUTHORIZATION_CODE_TIMEOUT_MS: string | undefined; OAUTH_ACCESS_TOKEN_TIMEOUT_MS: string | undefined; OAUTH_REFRESH_TOKEN_TIMEOUT_MS: string | undefined; From 872b46093760647528e0a8577db129102a4eb3ea Mon Sep 17 00:00:00 2001 From: Matt Filbert Date: Wed, 21 Jan 2026 12:06:15 -0600 Subject: [PATCH 02/20] @W-20976712: Add OAuth scope env vars to MCP bundle manifest Co-authored-by: Cursor --- src/scripts/createClaudeMcpBundleManifest.ts | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/scripts/createClaudeMcpBundleManifest.ts b/src/scripts/createClaudeMcpBundleManifest.ts index 5d13e12c..57423169 100644 --- a/src/scripts/createClaudeMcpBundleManifest.ts +++ b/src/scripts/createClaudeMcpBundleManifest.ts @@ -439,6 +439,32 @@ const envVars = { required: false, sensitive: false, }, + OAUTH_SCOPES_SUPPORTED: { + includeInUserConfig: false, + type: 'string', + title: 'OAuth Scopes Supported', + description: + 'A space- or comma-separated list of scopes supported by the MCP server.', + required: false, + sensitive: false, + }, + OAUTH_REQUIRED_SCOPES: { + includeInUserConfig: false, + type: 'string', + title: 'OAuth Required Scopes', + description: + 'A space- or comma-separated list of scopes required to access the MCP server.', + required: false, + sensitive: false, + }, + OAUTH_DISABLE_SCOPES: { + includeInUserConfig: false, + type: 'boolean', + title: 'OAuth Disable Scopes', + description: 'Disable scope enforcement and scope challenges when set to true.', + required: false, + sensitive: false, + }, OAUTH_AUTHORIZATION_CODE_TIMEOUT_MS: { includeInUserConfig: false, type: 'number', From d3ef157592f7aa50059b6e7dbec7f2019e37b07c Mon Sep 17 00:00:00 2001 From: Matt Filbert Date: Wed, 21 Jan 2026 12:24:10 -0600 Subject: [PATCH 03/20] @W-20976712: Fix formatting for OAuth scope changes Co-authored-by: Cursor --- src/scripts/createClaudeMcpBundleManifest.ts | 6 ++---- src/server/oauth/authMiddleware.ts | 10 +++++++--- src/server/oauth/authorize.ts | 3 +-- src/server/oauth/scopes.ts | 6 +++--- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/scripts/createClaudeMcpBundleManifest.ts b/src/scripts/createClaudeMcpBundleManifest.ts index 57423169..bd540e4f 100644 --- a/src/scripts/createClaudeMcpBundleManifest.ts +++ b/src/scripts/createClaudeMcpBundleManifest.ts @@ -443,8 +443,7 @@ const envVars = { includeInUserConfig: false, type: 'string', title: 'OAuth Scopes Supported', - description: - 'A space- or comma-separated list of scopes supported by the MCP server.', + description: 'A space- or comma-separated list of scopes supported by the MCP server.', required: false, sensitive: false, }, @@ -452,8 +451,7 @@ const envVars = { includeInUserConfig: false, type: 'string', title: 'OAuth Required Scopes', - description: - 'A space- or comma-separated list of scopes required to access the MCP server.', + description: 'A space- or comma-separated list of scopes required to access the MCP server.', required: false, sensitive: false, }, diff --git a/src/server/oauth/authMiddleware.ts b/src/server/oauth/authMiddleware.ts index bf133fa5..73e76ebd 100644 --- a/src/server/oauth/authMiddleware.ts +++ b/src/server/oauth/authMiddleware.ts @@ -47,7 +47,9 @@ export function authMiddleware(privateKey: KeyObject): RequestHandler { const baseUrl = `${req.protocol}://${req.get('host')}`; const { enforceScopes, requiredScopes } = getConfig().oauth; const scopeParam = - enforceScopes && requiredScopes.length > 0 ? `, scope="${formatScopes(requiredScopes)}"` : ''; + enforceScopes && requiredScopes.length > 0 + ? `, scope="${formatScopes(requiredScopes)}"` + : ''; res .status(401) .header( @@ -87,7 +89,9 @@ export function authMiddleware(privateKey: KeyObject): RequestHandler { const authInfo = result.value; const { enforceScopes, requiredScopes } = getConfig().oauth; if (enforceScopes && requiredScopes.length > 0) { - const missingRequiredScopes = requiredScopes.filter((scope) => !authInfo.scopes.includes(scope)); + const missingRequiredScopes = requiredScopes.filter( + (scope) => !authInfo.scopes.includes(scope), + ); if (missingRequiredScopes.length > 0) { const baseUrl = `${req.protocol}://${req.get('host')}`; const scopeParam = `scope="${formatScopes(requiredScopes)}"`; @@ -102,7 +106,7 @@ export function authMiddleware(privateKey: KeyObject): RequestHandler { }); res.write('event: error\n'); res.write( - `data: {"error": "insufficient_scope", "error_description": "Missing required scopes"}\n\n`, + 'data: {"error": "insufficient_scope", "error_description": "Missing required scopes"}\n\n', ); res.end(); return; diff --git a/src/server/oauth/authorize.ts b/src/server/oauth/authorize.ts index 2a656d4d..2cb1978f 100644 --- a/src/server/oauth/authorize.ts +++ b/src/server/oauth/authorize.ts @@ -120,8 +120,7 @@ export function authorize( return; } - const scopesToGrant = - enforceScopes && validScopes.length === 0 ? requiredScopes : validScopes; + const scopesToGrant = enforceScopes && validScopes.length === 0 ? requiredScopes : validScopes; if (enforceScopes) { const missingRequiredScopes = requiredScopes.filter((s) => !scopesToGrant.includes(s)); if (missingRequiredScopes.length > 0) { diff --git a/src/server/oauth/scopes.ts b/src/server/oauth/scopes.ts index 55de9e9a..90d529c8 100644 --- a/src/server/oauth/scopes.ts +++ b/src/server/oauth/scopes.ts @@ -114,7 +114,9 @@ export function getRequiredScopesForTool(toolName: ToolName | string): string[] 'get-view-data': ['tableau:views:download'], 'get-view-image': ['tableau:views:download'], 'list-all-pulse-metric-definitions': ['tableau:insight_definitions_metrics:read'], - 'list-pulse-metric-definitions-from-definition-ids': ['tableau:insight_definitions_metrics:read'], + 'list-pulse-metric-definitions-from-definition-ids': [ + 'tableau:insight_definitions_metrics:read', + ], 'list-pulse-metrics-from-metric-definition-id': ['tableau:insight_metrics:read'], 'list-pulse-metrics-from-metric-ids': ['tableau:insight_metrics:read'], 'list-pulse-metric-subscriptions': ['tableau:metric_subscriptions:read'], @@ -139,5 +141,3 @@ export function getRequiredScopesForTool(toolName: ToolName | string): string[] export function formatScopes(scopes: string[]): string { return scopes.join(' '); } - - From 0d299db3e9577d726b649bf827af86207d22f26a Mon Sep 17 00:00:00 2001 From: Matt Filbert Date: Wed, 21 Jan 2026 12:31:01 -0600 Subject: [PATCH 04/20] @W-20976712: Update OAuth config tests for scopes Co-authored-by: Cursor --- src/config.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/config.test.ts b/src/config.test.ts index 3a16e59a..cd3ddfe3 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -663,6 +663,9 @@ describe('Config', () => { jwePrivateKeyPath: 'path/to/private.pem', jwePrivateKeyPassphrase: undefined, dnsServers: ['1.1.1.1', '1.0.0.1'], + scopesSupported: [], + requiredScopes: [], + enforceScopes: false, ...defaultOAuthTimeoutMs, } as const; @@ -678,6 +681,9 @@ describe('Config', () => { jwePrivateKeyPath: '', jwePrivateKeyPassphrase: undefined, dnsServers: ['1.1.1.1', '1.0.0.1'], + scopesSupported: [], + requiredScopes: [], + enforceScopes: false, ...defaultOAuthTimeoutMs, }); }); From 96cb01f246107688d59cf968ba1b720af5569a43 Mon Sep 17 00:00:00 2001 From: Matt Filbert Date: Wed, 21 Jan 2026 12:33:21 -0600 Subject: [PATCH 05/20] @W-20976712: Stabilize list-workbooks E2E filter test Co-authored-by: Cursor --- tests/e2e/workbooks/listWorkbooks.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/e2e/workbooks/listWorkbooks.test.ts b/tests/e2e/workbooks/listWorkbooks.test.ts index 3007ff3a..bde9b603 100644 --- a/tests/e2e/workbooks/listWorkbooks.test.ts +++ b/tests/e2e/workbooks/listWorkbooks.test.ts @@ -34,11 +34,13 @@ describe('list-workbooks', () => { const workbooks = await callTool('list-workbooks', { env, schema: z.array(workbookSchema), - toolArgs: { filter: 'name:eq:Super*' }, + toolArgs: { filter: 'name:eq:Superstore' }, }); - expect(workbooks).toHaveLength(1); - expect(workbooks[0]).toMatchObject({ + expect(workbooks.length).greaterThan(0); + const workbook = workbooks.find((candidate) => candidate.name === 'Superstore'); + + expect(workbook).toMatchObject({ id: superstore.id, name: 'Superstore', defaultViewId: superstore.defaultViewId, From 255f07a3b7f0b9cd5a39198a464567d1ad5edf3b Mon Sep 17 00:00:00 2001 From: Matt Filbert Date: Wed, 21 Jan 2026 12:40:12 -0600 Subject: [PATCH 06/20] @W-20976712: Update OAuth tests for scope field Co-authored-by: Cursor --- tests/oauth/clientCredentialsGrant.test.ts | 2 ++ tests/oauth/exchangeAuthzCodeForAccessToken.ts | 1 + tests/oauth/refreshTokenGrant.test.ts | 1 + 3 files changed, 4 insertions(+) diff --git a/tests/oauth/clientCredentialsGrant.test.ts b/tests/oauth/clientCredentialsGrant.test.ts index d07384a2..0acd5b08 100644 --- a/tests/oauth/clientCredentialsGrant.test.ts +++ b/tests/oauth/clientCredentialsGrant.test.ts @@ -65,6 +65,7 @@ describe('client credentials grant type', () => { refresh_token: undefined, token_type: 'Bearer', expires_in: 3600, + scope: '', }); }); @@ -88,6 +89,7 @@ describe('client credentials grant type', () => { refresh_token: undefined, token_type: 'Bearer', expires_in: 3600, + scope: '', }); }); diff --git a/tests/oauth/exchangeAuthzCodeForAccessToken.ts b/tests/oauth/exchangeAuthzCodeForAccessToken.ts index 3bd7e9d7..966c127f 100644 --- a/tests/oauth/exchangeAuthzCodeForAccessToken.ts +++ b/tests/oauth/exchangeAuthzCodeForAccessToken.ts @@ -50,6 +50,7 @@ export async function exchangeAuthzCodeForAccessToken(app: express.Application): refresh_token: expect.any(String), token_type: 'Bearer', expires_in: 3600, + scope: '', }); return tokenResponse.body; diff --git a/tests/oauth/refreshTokenGrant.test.ts b/tests/oauth/refreshTokenGrant.test.ts index c8a1e9d6..65b2d3cc 100644 --- a/tests/oauth/refreshTokenGrant.test.ts +++ b/tests/oauth/refreshTokenGrant.test.ts @@ -120,6 +120,7 @@ describe('refresh token grant type', () => { refresh_token: expect.any(String), token_type: 'Bearer', expires_in: 3600, + scope: '', }); // Verify that the refresh token is rotated From b15baf15272aeec27aeac369578c0a4fc488d3a9 Mon Sep 17 00:00:00 2001 From: Matt Filbert Date: Tue, 10 Feb 2026 17:33:53 -0600 Subject: [PATCH 07/20] @W-20976712: Align scopes with MCP guidance and tool mappings Co-authored-by: Cursor --- docs/docs/configuration/mcp-config/oauth.md | 41 ------ src/restApiInstance.ts | 11 +- .../.well-known/oauth-authorization-server.ts | 6 +- src/server/oauth/authorize.ts | 5 +- src/server/oauth/scopes.ts | 129 +++++++++++++----- src/tools/contentExploration/searchContent.ts | 3 +- .../getDatasourceMetadata.ts | 3 +- src/tools/listDatasources/listDatasources.ts | 3 +- .../generatePulseInsightBriefTool.ts | 3 +- ...neratePulseMetricValueInsightBundleTool.ts | 3 +- .../listAllPulseMetricDefinitions.ts | 3 +- ...PulseMetricDefinitionsFromDefinitionIds.ts | 3 +- .../listPulseMetricSubscriptions.ts | 5 +- .../listPulseMetricsFromMetricDefinitionId.ts | 3 +- .../listPulseMetricsFromMetricIds.ts | 3 +- src/tools/queryDatasource/queryDatasource.ts | 3 +- src/tools/resourceAccessChecker.ts | 7 +- src/tools/views/getViewData.ts | 3 +- src/tools/views/getViewImage.ts | 3 +- src/tools/views/listViews.ts | 3 +- src/tools/workbooks/getWorkbook.ts | 3 +- src/tools/workbooks/listWorkbooks.ts | 3 +- tests/oauth/oauth.test.ts | 2 +- 23 files changed, 140 insertions(+), 111 deletions(-) diff --git a/docs/docs/configuration/mcp-config/oauth.md b/docs/docs/configuration/mcp-config/oauth.md index f6a053b1..9b07a787 100644 --- a/docs/docs/configuration/mcp-config/oauth.md +++ b/docs/docs/configuration/mcp-config/oauth.md @@ -182,47 +182,6 @@ A space- or comma-separated list of scopes supported by the MCP server.
-## Recommended scope set (beta) - -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. - -Suggested initial scopes: - -- `tableau:content:read` -- `tableau:content:write` -- `tableau:datasource:query` -- `tableau:datasource:read` -- `tableau:workbook:read` -- `tableau:workbook:write` -- `tableau:view:read` -- `tableau:view:download` -- `tableau:project:read` -- `tableau:metrics:read` -- `tableau:insights:read` - -Example configuration: - -``` -OAUTH_SCOPES_SUPPORTED=tableau:content:read tableau:content:write tableau:datasource:query tableau:datasource:read tableau:workbook:read tableau:workbook:write tableau:view:read tableau:view:download tableau:project:read tableau:metrics:read tableau:insights:read -OAUTH_REQUIRED_SCOPES=tableau:content:read tableau:content:write tableau:datasource:query tableau:datasource:read tableau:workbook:read tableau:workbook:write tableau:view:read tableau:view:download tableau:project:read tableau:metrics:read tableau:insights:read -``` - -
- -## Why MCP scopes (in addition to Tableau API scopes) - -Tableau API scopes alone are not sufficient to protect all MCP functionality. - -- 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. - -
- ### `OAUTH_REQUIRED_SCOPES` A space- or comma-separated list of scopes required to access the MCP server. diff --git a/src/restApiInstance.ts b/src/restApiInstance.ts index 50ba844d..d25c03fa 100644 --- a/src/restApiInstance.ts +++ b/src/restApiInstance.ts @@ -3,6 +3,7 @@ import { RequestId } from '@modelcontextprotocol/sdk/types.js'; import { Config, getConfig } from './config.js'; import { log, shouldLogWhenLevelIsAtLeast } from './logging/log.js'; import { maskRequest, maskResponse } from './logging/secretMask.js'; +import { TableauApiScope } from './server/oauth/scopes.js'; import { AxiosResponseInterceptorConfig, ErrorInterceptor, @@ -20,15 +21,7 @@ import { isAxiosError } from './utils/axios.js'; import { getExceptionMessage } from './utils/getExceptionMessage.js'; import invariant from './utils/invariant.js'; -type JwtScopes = - | 'tableau:viz_data_service:read' - | 'tableau:content:read' - | 'tableau:insight_definitions_metrics:read' - | 'tableau:insight_metrics:read' - | 'tableau:metric_subscriptions:read' - | 'tableau:insights:read' - | 'tableau:views:download' - | 'tableau:insight_brief:create'; +type JwtScopes = TableauApiScope; const getNewRestApiInstanceAsync = async ( config: Config, diff --git a/src/server/oauth/.well-known/oauth-authorization-server.ts b/src/server/oauth/.well-known/oauth-authorization-server.ts index c60af8ac..d85df2ee 100644 --- a/src/server/oauth/.well-known/oauth-authorization-server.ts +++ b/src/server/oauth/.well-known/oauth-authorization-server.ts @@ -20,7 +20,11 @@ export function oauthAuthorizationServer(app: express.Application): void { grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'], code_challenge_methods_supported: ['S256'], scopes_supported: scopesSupported, - token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'], + token_endpoint_auth_methods_supported: [ + 'none', + 'client_secret_basic', + 'client_secret_post', + ], subject_types_supported: ['public'], client_id_metadata_document_supported: true, }); diff --git a/src/server/oauth/authorize.ts b/src/server/oauth/authorize.ts index 2cb1978f..03078230 100644 --- a/src/server/oauth/authorize.ts +++ b/src/server/oauth/authorize.ts @@ -16,7 +16,7 @@ import { generateCodeChallenge } from './generateCodeChallenge.js'; import { isValidRedirectUri } from './isValidRedirectUri.js'; import { TABLEAU_CLOUD_SERVER_URL } from './provider.js'; import { cimdMetadataSchema, ClientMetadata, mcpAuthorizeSchema } from './schemas.js'; -import { formatScopes, parseScopes, validateScopes } from './scopes.js'; +import { parseScopes, validateScopes } from './scopes.js'; import { PendingAuthorization } from './types.js'; /** @@ -168,9 +168,6 @@ export function authorize( oauthUrl.searchParams.set('target_site', config.siteName); oauthUrl.searchParams.set('device_name', getDeviceName(redirect_uri, state ?? '')); oauthUrl.searchParams.set('client_type', 'tableau-mcp'); - if (scopesToGrant.length > 0) { - oauthUrl.searchParams.set('scope', formatScopes(scopesToGrant)); - } 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. diff --git a/src/server/oauth/scopes.ts b/src/server/oauth/scopes.ts index 90d529c8..a748c73b 100644 --- a/src/server/oauth/scopes.ts +++ b/src/server/oauth/scopes.ts @@ -5,6 +5,7 @@ * for scope validation and management. */ +import { getConfig } from '../../config.js'; import { ToolName } from '../../tools/toolName.js'; /** @@ -14,6 +15,15 @@ import { ToolName } from '../../tools/toolName.js'; * when authenticating with the MCP server. */ export type McpScope = + | 'tableau:mcp:content:read' + | 'tableau:mcp:datasource:read' + | 'tableau:mcp:workbook:read' + | 'tableau:mcp:view:read' + | 'tableau:mcp:view:download' + | 'tableau:mcp:pulse:read' + | 'tableau:mcp:insight:create'; + +export type TableauApiScope = | 'tableau:content:read' | 'tableau:viz_data_service:read' | 'tableau:views:download' @@ -29,28 +39,94 @@ export type McpScope = * This list can be configured via environment variable or config file. */ export const DEFAULT_SCOPES_SUPPORTED: 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', + 'tableau:mcp:content:read', + 'tableau:mcp:datasource:read', + 'tableau:mcp:workbook:read', + 'tableau:mcp:view:read', + 'tableau:mcp:view:download', + 'tableau:mcp:pulse:read', + 'tableau:mcp:insight:create', ]; /** * Minimal default scopes suggested when no specific tool is known. */ -export const DEFAULT_REQUIRED_SCOPES: McpScope[] = ['tableau:content:read']; +export const DEFAULT_REQUIRED_SCOPES: McpScope[] = ['tableau:mcp:content:read']; /** * 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); + return DEFAULT_SCOPES_SUPPORTED.some((supported) => supported === scope); } +const toolScopeMap: Record = { + 'list-datasources': { + mcp: ['tableau:mcp:datasource:read'], + api: ['tableau:content:read'], + }, + 'list-workbooks': { + mcp: ['tableau:mcp:workbook:read'], + api: ['tableau:content:read'], + }, + 'list-views': { + mcp: ['tableau:mcp:view:read'], + api: ['tableau:content:read'], + }, + 'query-datasource': { + mcp: ['tableau:mcp:datasource:read'], + api: ['tableau:viz_data_service:read'], + }, + 'get-datasource-metadata': { + mcp: ['tableau:mcp:datasource:read'], + api: ['tableau:content:read', 'tableau:viz_data_service:read'], + }, + 'get-workbook': { + mcp: ['tableau:mcp:workbook:read'], + api: ['tableau:content:read'], + }, + 'get-view-data': { + mcp: ['tableau:mcp:view:download'], + api: ['tableau:views:download'], + }, + 'get-view-image': { + mcp: ['tableau:mcp:view:download'], + api: ['tableau:views:download'], + }, + 'list-all-pulse-metric-definitions': { + mcp: ['tableau:mcp:pulse:read'], + api: ['tableau:insight_definitions_metrics:read'], + }, + 'list-pulse-metric-definitions-from-definition-ids': { + mcp: ['tableau:mcp:pulse:read'], + api: ['tableau:insight_definitions_metrics:read'], + }, + 'list-pulse-metrics-from-metric-definition-id': { + mcp: ['tableau:mcp:pulse:read'], + api: ['tableau:insight_metrics:read'], + }, + 'list-pulse-metrics-from-metric-ids': { + mcp: ['tableau:mcp:pulse:read'], + api: ['tableau:insight_metrics:read'], + }, + 'list-pulse-metric-subscriptions': { + mcp: ['tableau:mcp:pulse:read'], + api: ['tableau:metric_subscriptions:read'], + }, + 'generate-pulse-metric-value-insight-bundle': { + mcp: ['tableau:mcp:insight:create'], + api: ['tableau:insights:read'], + }, + 'generate-pulse-insight-brief': { + mcp: ['tableau:mcp:insight:create'], + api: ['tableau:insight_brief:create'], + }, + 'search-content': { + mcp: ['tableau:mcp:content:read'], + api: ['tableau:content:read'], + }, +}; + /** * Parses a space-separated scope string into an array of scopes * @@ -103,33 +179,16 @@ export function validateScopes( * @param endpoint - The MCP endpoint or tool name * @returns Array of required scopes for the endpoint */ -export function getRequiredScopesForTool(toolName: ToolName | string): string[] { - const toolScopeMap: Record = { - 'list-datasources': ['tableau:content:read'], - 'list-workbooks': ['tableau:content:read'], - 'list-views': ['tableau:content:read'], - 'query-datasource': ['tableau:viz_data_service:read'], - 'get-datasource-metadata': ['tableau:content:read', 'tableau:viz_data_service:read'], - 'get-workbook': ['tableau:content:read'], - 'get-view-data': ['tableau:views:download'], - 'get-view-image': ['tableau:views:download'], - 'list-all-pulse-metric-definitions': ['tableau:insight_definitions_metrics:read'], - 'list-pulse-metric-definitions-from-definition-ids': [ - 'tableau:insight_definitions_metrics:read', - ], - 'list-pulse-metrics-from-metric-definition-id': ['tableau:insight_metrics:read'], - 'list-pulse-metrics-from-metric-ids': ['tableau:insight_metrics:read'], - 'list-pulse-metric-subscriptions': ['tableau:metric_subscriptions:read'], - 'generate-pulse-metric-value-insight-bundle': ['tableau:insights:read'], - 'generate-pulse-insight-brief': ['tableau:insight_brief:create'], - 'search-content': ['tableau:content:read'], - }; - - if (toolName in toolScopeMap) { - return toolScopeMap[toolName as ToolName]; +export function getRequiredScopesForTool(toolName: ToolName): McpScope[] { + if (!getConfig().oauth.enforceScopes) { + return []; } - return DEFAULT_REQUIRED_SCOPES; + return toolScopeMap[toolName].mcp; +} + +export function getRequiredApiScopesForTool(toolName: ToolName): TableauApiScope[] { + return toolScopeMap[toolName].api; } /** diff --git a/src/tools/contentExploration/searchContent.ts b/src/tools/contentExploration/searchContent.ts index a99444f4..4c9f9b69 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 { getRequiredApiScopesForTool } from '../../server/oauth/scopes.js'; import { getSiteLuidFromAccessToken } from '../../utils/getSiteLuidFromAccessToken.js'; import { Tool } from '../tool.js'; import { @@ -80,7 +81,7 @@ This tool searches across all supported content types for objects relevant to th config, requestId, server, - jwtScopes: ['tableau:content:read'], + jwtScopes: getRequiredApiScopesForTool('search-content'), signal, authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { diff --git a/src/tools/getDatasourceMetadata/getDatasourceMetadata.ts b/src/tools/getDatasourceMetadata/getDatasourceMetadata.ts index 08fdc5ba..781ee402 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 { getRequiredApiScopesForTool } from '../../server/oauth/scopes.js'; import { getSiteLuidFromAccessToken } from '../../utils/getSiteLuidFromAccessToken.js'; import { getVizqlDataServiceDisabledError } from '../getVizqlDataServiceDisabledError.js'; import { resourceAccessChecker } from '../resourceAccessChecker.js'; @@ -143,7 +144,7 @@ export const getGetDatasourceMetadataTool = (server: Server): Tool { diff --git a/src/tools/listDatasources/listDatasources.ts b/src/tools/listDatasources/listDatasources.ts index 037c995c..39c45be9 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 { getRequiredApiScopesForTool } from '../../server/oauth/scopes.js'; import { getSiteLuidFromAccessToken } from '../../utils/getSiteLuidFromAccessToken.js'; import { paginate } from '../../utils/paginate.js'; import { genericFilterDescription } from '../genericFilterDescription.js'; @@ -95,7 +96,7 @@ export const getListDatasourcesTool = (server: Server): Tool { diff --git a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts index 1059bfc9..4dc14f5a 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 { getRequiredApiScopesForTool } from '../../../server/oauth/scopes.js'; import { getSiteLuidFromAccessToken } from '../../../utils/getSiteLuidFromAccessToken.js'; import { Tool } from '../../tool.js'; import { getPulseDisabledError } from '../getPulseDisabledError.js'; @@ -235,7 +236,7 @@ An insight brief is an AI-generated response to questions about Pulse metrics. I config, requestId, server, - jwtScopes: ['tableau:insight_brief:create'], + jwtScopes: getRequiredApiScopesForTool('generate-pulse-insight-brief'), signal, authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => diff --git a/src/tools/pulse/generateMetricValueInsightBundle/generatePulseMetricValueInsightBundleTool.ts b/src/tools/pulse/generateMetricValueInsightBundle/generatePulseMetricValueInsightBundleTool.ts index be057fcf..4b178d8d 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 { getRequiredApiScopesForTool } from '../../../server/oauth/scopes.js'; import { getSiteLuidFromAccessToken } from '../../../utils/getSiteLuidFromAccessToken.js'; import { Tool } from '../../tool.js'; import { getPulseDisabledError } from '../getPulseDisabledError.js'; @@ -186,7 +187,7 @@ Generate an insight bundle for the current aggregated value for Pulse Metric usi config, requestId, server, - jwtScopes: ['tableau:insights:read'], + jwtScopes: getRequiredApiScopesForTool('generate-pulse-metric-value-insight-bundle'), signal, authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => diff --git a/src/tools/pulse/listAllMetricDefinitions/listAllPulseMetricDefinitions.ts b/src/tools/pulse/listAllMetricDefinitions/listAllPulseMetricDefinitions.ts index 761d8d44..13b2907b 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 { getRequiredApiScopesForTool } from '../../../server/oauth/scopes.js'; import { getSiteLuidFromAccessToken } from '../../../utils/getSiteLuidFromAccessToken.js'; import { pulsePaginate } from '../../../utils/paginate.js'; import { Tool } from '../../tool.js'; @@ -73,7 +74,7 @@ Retrieves a list of all published Pulse Metric Definitions using the Tableau RES config, requestId, server, - jwtScopes: ['tableau:insight_definitions_metrics:read'], + jwtScopes: getRequiredApiScopesForTool('list-all-pulse-metric-definitions'), signal, authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { diff --git a/src/tools/pulse/listMetricDefinitionsFromDefinitionIds/listPulseMetricDefinitionsFromDefinitionIds.ts b/src/tools/pulse/listMetricDefinitionsFromDefinitionIds/listPulseMetricDefinitionsFromDefinitionIds.ts index 38aa4fd9..d1f3faf4 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 { getRequiredApiScopesForTool } from '../../../server/oauth/scopes.js'; import { getSiteLuidFromAccessToken } from '../../../utils/getSiteLuidFromAccessToken.js'; import { Tool } from '../../tool.js'; import { constrainPulseDefinitions } from '../constrainPulseDefinitions.js'; @@ -73,7 +74,7 @@ Retrieves a list of specific Pulse Metric Definitions using the Tableau REST API config, requestId, server, - jwtScopes: ['tableau:insight_definitions_metrics:read'], + jwtScopes: getRequiredApiScopesForTool('list-pulse-metric-definitions-from-definition-ids'), signal, authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { diff --git a/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts b/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts index 5c330115..3e22928e 100644 --- a/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts +++ b/src/tools/pulse/listMetricSubscriptions/listPulseMetricSubscriptions.ts @@ -5,6 +5,7 @@ import { 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 { getRequiredApiScopesForTool } from '../../../server/oauth/scopes.js'; import { getExceptionMessage } from '../../../utils/getExceptionMessage.js'; import { getSiteLuidFromAccessToken } from '../../../utils/getSiteLuidFromAccessToken.js'; import { RestApiArgs } from '../../resourceAccessChecker.js'; @@ -47,7 +48,7 @@ Retrieves a list of published Pulse Metric Subscriptions for the current user us config, requestId, server, - jwtScopes: ['tableau:metric_subscriptions:read'], + jwtScopes: getRequiredApiScopesForTool('list-pulse-metric-subscriptions'), signal, authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { @@ -109,7 +110,7 @@ export async function constrainPulseMetricSubscriptions({ config, requestId, server, - jwtScopes: ['tableau:insight_metrics:read'], + jwtScopes: getRequiredApiScopesForTool('list-pulse-metrics-from-metric-ids'), signal, callback: async (restApi) => { return await restApi.pulseMethods.listPulseMetricsFromMetricIds( diff --git a/src/tools/pulse/listMetricsFromMetricDefinitionId/listPulseMetricsFromMetricDefinitionId.ts b/src/tools/pulse/listMetricsFromMetricDefinitionId/listPulseMetricsFromMetricDefinitionId.ts index 45a1fae0..d63059be 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 { getRequiredApiScopesForTool } from '../../../server/oauth/scopes.js'; import { getSiteLuidFromAccessToken } from '../../../utils/getSiteLuidFromAccessToken.js'; import { Tool } from '../../tool.js'; import { constrainPulseMetrics } from '../constrainPulseMetrics.js'; @@ -55,7 +56,7 @@ Retrieves a list of published Pulse Metrics from a Pulse Metric Definition using config, requestId, server, - jwtScopes: ['tableau:insight_definitions_metrics:read'], + jwtScopes: getRequiredApiScopesForTool('list-pulse-metrics-from-metric-definition-id'), signal, authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { diff --git a/src/tools/pulse/listMetricsFromMetricIds/listPulseMetricsFromMetricIds.ts b/src/tools/pulse/listMetricsFromMetricIds/listPulseMetricsFromMetricIds.ts index ffc4f278..687038d7 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 { getRequiredApiScopesForTool } from '../../../server/oauth/scopes.js'; import { getSiteLuidFromAccessToken } from '../../../utils/getSiteLuidFromAccessToken.js'; import { Tool } from '../../tool.js'; import { constrainPulseMetrics } from '../constrainPulseMetrics.js'; @@ -54,7 +55,7 @@ Retrieves a list of published Pulse Metrics from a list of metric IDs using the config, requestId, server, - jwtScopes: ['tableau:insight_metrics:read'], + jwtScopes: getRequiredApiScopesForTool('list-pulse-metrics-from-metric-ids'), signal, authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { diff --git a/src/tools/queryDatasource/queryDatasource.ts b/src/tools/queryDatasource/queryDatasource.ts index 89ac82db..292f30ef 100644 --- a/src/tools/queryDatasource/queryDatasource.ts +++ b/src/tools/queryDatasource/queryDatasource.ts @@ -13,6 +13,7 @@ import { } from '../../sdks/tableau/apis/vizqlDataServiceApi.js'; import { Server } from '../../server.js'; import { getTableauAuthInfo } from '../../server/oauth/getTableauAuthInfo.js'; +import { getRequiredApiScopesForTool } from '../../server/oauth/scopes.js'; import { TableauAuthInfo } from '../../server/oauth/schemas.js'; import { getSiteLuidFromAccessToken } from '../../utils/getSiteLuidFromAccessToken.js'; import { getResultForTableauVersion } from '../../utils/isTableauVersionAtLeast.js'; @@ -137,7 +138,7 @@ export const getQueryDatasourceTool = ( config, requestId, server, - jwtScopes: ['tableau:viz_data_service:read'], + jwtScopes: getRequiredApiScopesForTool('query-datasource'), signal, authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { diff --git a/src/tools/resourceAccessChecker.ts b/src/tools/resourceAccessChecker.ts index b0bb9e79..e6de4242 100644 --- a/src/tools/resourceAccessChecker.ts +++ b/src/tools/resourceAccessChecker.ts @@ -7,6 +7,7 @@ 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 { getRequiredApiScopesForTool } from '../server/oauth/scopes.js'; type AllowedResult = | { allowed: true; content?: T } @@ -173,7 +174,7 @@ class ResourceAccessChecker { config, requestId, server, - jwtScopes: ['tableau:content:read'], + jwtScopes: getRequiredApiScopesForTool('list-datasources'), signal, callback: async (restApi) => await restApi.datasourcesMethods.queryDatasource({ @@ -264,7 +265,7 @@ class ResourceAccessChecker { config, requestId, server, - jwtScopes: ['tableau:content:read'], + jwtScopes: getRequiredApiScopesForTool('get-workbook'), signal, callback: async (restApi) => await restApi.workbooksMethods.getWorkbook({ @@ -345,7 +346,7 @@ class ResourceAccessChecker { config, requestId, server, - jwtScopes: ['tableau:content:read'], + jwtScopes: getRequiredApiScopesForTool('list-views'), signal, callback: async (restApi) => { return await restApi.viewsMethods.getView({ diff --git a/src/tools/views/getViewData.ts b/src/tools/views/getViewData.ts index d75d0792..b4cd9ca9 100644 --- a/src/tools/views/getViewData.ts +++ b/src/tools/views/getViewData.ts @@ -6,6 +6,7 @@ import { getConfig } from '../../config.js'; import { useRestApi } from '../../restApiInstance.js'; import { Server } from '../../server.js'; import { getTableauAuthInfo } from '../../server/oauth/getTableauAuthInfo.js'; +import { getRequiredApiScopesForTool } from '../../server/oauth/scopes.js'; import { getSiteLuidFromAccessToken } from '../../utils/getSiteLuidFromAccessToken.js'; import { resourceAccessChecker } from '../resourceAccessChecker.js'; import { Tool } from '../tool.js'; @@ -60,7 +61,7 @@ export const getGetViewDataTool = (server: Server): Tool => config, requestId, server, - jwtScopes: ['tableau:views:download'], + jwtScopes: getRequiredApiScopesForTool('get-view-data'), signal, authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { diff --git a/src/tools/views/getViewImage.ts b/src/tools/views/getViewImage.ts index 0b77771d..25ca5652 100644 --- a/src/tools/views/getViewImage.ts +++ b/src/tools/views/getViewImage.ts @@ -6,6 +6,7 @@ import { getConfig } from '../../config.js'; import { useRestApi } from '../../restApiInstance.js'; import { Server } from '../../server.js'; import { getTableauAuthInfo } from '../../server/oauth/getTableauAuthInfo.js'; +import { getRequiredApiScopesForTool } from '../../server/oauth/scopes.js'; import { getSiteLuidFromAccessToken } from '../../utils/getSiteLuidFromAccessToken.js'; import { convertPngDataToToolResult } from '../convertPngDataToToolResult.js'; import { resourceAccessChecker } from '../resourceAccessChecker.js'; @@ -63,7 +64,7 @@ export const getGetViewImageTool = (server: Server): Tool = config, requestId, server, - jwtScopes: ['tableau:views:download'], + jwtScopes: getRequiredApiScopesForTool('get-view-image'), signal, authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { diff --git a/src/tools/views/listViews.ts b/src/tools/views/listViews.ts index e8166b16..257b35e0 100644 --- a/src/tools/views/listViews.ts +++ b/src/tools/views/listViews.ts @@ -7,6 +7,7 @@ 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 { getRequiredApiScopesForTool } from '../../server/oauth/scopes.js'; import { getSiteLuidFromAccessToken } from '../../utils/getSiteLuidFromAccessToken.js'; import { paginate } from '../../utils/paginate.js'; import { genericFilterDescription } from '../genericFilterDescription.js'; @@ -85,7 +86,7 @@ export const getListViewsTool = (server: Server): Tool => { config, requestId, server, - jwtScopes: ['tableau:content:read'], + jwtScopes: getRequiredApiScopesForTool('list-views'), signal, authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { diff --git a/src/tools/workbooks/getWorkbook.ts b/src/tools/workbooks/getWorkbook.ts index 84cc9617..41310fbf 100644 --- a/src/tools/workbooks/getWorkbook.ts +++ b/src/tools/workbooks/getWorkbook.ts @@ -7,6 +7,7 @@ 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 { getRequiredApiScopesForTool } from '../../server/oauth/scopes.js'; import { getSiteLuidFromAccessToken } from '../../utils/getSiteLuidFromAccessToken.js'; import { resourceAccessChecker } from '../resourceAccessChecker.js'; import { ConstrainedResult, Tool } from '../tool.js'; @@ -61,7 +62,7 @@ export const getGetWorkbookTool = (server: Server): Tool => config, requestId, server, - jwtScopes: ['tableau:content:read'], + jwtScopes: getRequiredApiScopesForTool('get-workbook'), signal, authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { diff --git a/src/tools/workbooks/listWorkbooks.ts b/src/tools/workbooks/listWorkbooks.ts index f4803268..ec593f01 100644 --- a/src/tools/workbooks/listWorkbooks.ts +++ b/src/tools/workbooks/listWorkbooks.ts @@ -7,6 +7,7 @@ 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 { getRequiredApiScopesForTool } from '../../server/oauth/scopes.js'; import { getSiteLuidFromAccessToken } from '../../utils/getSiteLuidFromAccessToken.js'; import { paginate } from '../../utils/paginate.js'; import { genericFilterDescription } from '../genericFilterDescription.js'; @@ -82,7 +83,7 @@ export const getListWorkbooksTool = (server: Server): Tool config, requestId, server, - jwtScopes: ['tableau:content:read'], + jwtScopes: getRequiredApiScopesForTool('list-workbooks'), signal, authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { diff --git a/tests/oauth/oauth.test.ts b/tests/oauth/oauth.test.ts index 89722a33..178f54c0 100644 --- a/tests/oauth/oauth.test.ts +++ b/tests/oauth/oauth.test.ts @@ -94,7 +94,7 @@ describe('OAuth', () => { grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'], code_challenge_methods_supported: ['S256'], scopes_supported: [], - token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'], + token_endpoint_auth_methods_supported: ['none', 'client_secret_basic', 'client_secret_post'], subject_types_supported: ['public'], client_id_metadata_document_supported: true, }); From 872251b751e91678dbc620d53c810613b6005fe1 Mon Sep 17 00:00:00 2001 From: Matt Filbert Date: Tue, 10 Feb 2026 17:34:50 -0600 Subject: [PATCH 08/20] @W-20976712: Resolve lint issues and telemetry env merge Co-authored-by: Cursor --- src/restApiInstance.ts | 2 +- src/scripts/createClaudeMcpBundleManifest.ts | 16 ++++++++++++++++ .../.well-known/oauth-authorization-server.ts | 6 +----- ...istPulseMetricDefinitionsFromDefinitionIds.ts | 4 +++- src/tools/resourceAccessChecker.ts | 2 +- types/process-env.d.ts | 2 ++ 6 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/restApiInstance.ts b/src/restApiInstance.ts index d25c03fa..0da7cbec 100644 --- a/src/restApiInstance.ts +++ b/src/restApiInstance.ts @@ -3,7 +3,6 @@ import { RequestId } from '@modelcontextprotocol/sdk/types.js'; import { Config, getConfig } from './config.js'; import { log, shouldLogWhenLevelIsAtLeast } from './logging/log.js'; import { maskRequest, maskResponse } from './logging/secretMask.js'; -import { TableauApiScope } from './server/oauth/scopes.js'; import { AxiosResponseInterceptorConfig, ErrorInterceptor, @@ -16,6 +15,7 @@ import { } from './sdks/tableau/interceptors.js'; import { RestApi } from './sdks/tableau/restApi.js'; import { Server, userAgent } from './server.js'; +import { TableauApiScope } from './server/oauth/scopes.js'; import { TableauAuthInfo } from './server/oauth/schemas.js'; import { isAxiosError } from './utils/axios.js'; import { getExceptionMessage } from './utils/getExceptionMessage.js'; diff --git a/src/scripts/createClaudeMcpBundleManifest.ts b/src/scripts/createClaudeMcpBundleManifest.ts index bd540e4f..69b84d7f 100644 --- a/src/scripts/createClaudeMcpBundleManifest.ts +++ b/src/scripts/createClaudeMcpBundleManifest.ts @@ -190,6 +190,22 @@ const envVars = { required: false, sensitive: false, }, + TELEMETRY_PROVIDER: { + includeInUserConfig: false, + type: 'string', + title: 'Telemetry Provider', + description: 'The telemetry provider to use for server telemetry.', + required: false, + sensitive: false, + }, + TELEMETRY_PROVIDER_CONFIG: { + includeInUserConfig: false, + type: 'string', + title: 'Telemetry Provider Config', + description: 'Provider-specific configuration for telemetry.', + required: false, + sensitive: false, + }, DEFAULT_LOG_LEVEL: { includeInUserConfig: false, type: 'string', diff --git a/src/server/oauth/.well-known/oauth-authorization-server.ts b/src/server/oauth/.well-known/oauth-authorization-server.ts index d85df2ee..a6d10cda 100644 --- a/src/server/oauth/.well-known/oauth-authorization-server.ts +++ b/src/server/oauth/.well-known/oauth-authorization-server.ts @@ -20,11 +20,7 @@ export function oauthAuthorizationServer(app: express.Application): void { grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'], code_challenge_methods_supported: ['S256'], scopes_supported: scopesSupported, - token_endpoint_auth_methods_supported: [ - 'none', - 'client_secret_basic', - 'client_secret_post', - ], + token_endpoint_auth_methods_supported: ['none', 'client_secret_basic', 'client_secret_post'], subject_types_supported: ['public'], client_id_metadata_document_supported: true, }); diff --git a/src/tools/pulse/listMetricDefinitionsFromDefinitionIds/listPulseMetricDefinitionsFromDefinitionIds.ts b/src/tools/pulse/listMetricDefinitionsFromDefinitionIds/listPulseMetricDefinitionsFromDefinitionIds.ts index d1f3faf4..5c44c716 100644 --- a/src/tools/pulse/listMetricDefinitionsFromDefinitionIds/listPulseMetricDefinitionsFromDefinitionIds.ts +++ b/src/tools/pulse/listMetricDefinitionsFromDefinitionIds/listPulseMetricDefinitionsFromDefinitionIds.ts @@ -74,7 +74,9 @@ Retrieves a list of specific Pulse Metric Definitions using the Tableau REST API config, requestId, server, - jwtScopes: getRequiredApiScopesForTool('list-pulse-metric-definitions-from-definition-ids'), + jwtScopes: getRequiredApiScopesForTool( + 'list-pulse-metric-definitions-from-definition-ids', + ), signal, authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => { diff --git a/src/tools/resourceAccessChecker.ts b/src/tools/resourceAccessChecker.ts index e6de4242..8627c464 100644 --- a/src/tools/resourceAccessChecker.ts +++ b/src/tools/resourceAccessChecker.ts @@ -6,8 +6,8 @@ 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 { getRequiredApiScopesForTool } from '../server/oauth/scopes.js'; +import { getExceptionMessage } from '../utils/getExceptionMessage.js'; type AllowedResult = | { allowed: true; content?: T } diff --git a/types/process-env.d.ts b/types/process-env.d.ts index 311792cd..cee4fe2f 100644 --- a/types/process-env.d.ts +++ b/types/process-env.d.ts @@ -55,6 +55,8 @@ export interface ProcessEnvEx { OAUTH_AUTHORIZATION_CODE_TIMEOUT_MS: string | undefined; OAUTH_ACCESS_TOKEN_TIMEOUT_MS: string | undefined; OAUTH_REFRESH_TOKEN_TIMEOUT_MS: string | undefined; + TELEMETRY_PROVIDER: string | undefined; + TELEMETRY_PROVIDER_CONFIG: string | undefined; PRODUCT_TELEMETRY_ENABLED: string | undefined; PRODUCT_TELEMETRY_ENDPOINT: string | undefined; } From 4e8f547b0bedae462af28674f3c6ea1746309d3a Mon Sep 17 00:00:00 2001 From: Matt Filbert Date: Fri, 23 Jan 2026 14:47:55 -0600 Subject: [PATCH 09/20] @W-20976712: Fix telemetry provider variable names Co-authored-by: Cursor --- src/config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/config.ts b/src/config.ts index 1e737d3d..d433306d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -271,16 +271,16 @@ export class Config { enforceScopes, }; - const parsedProvider = isTelemetryProvider(telemetryProvider) ? telemetryProvider : 'noop'; + const parsedProvider = isTelemetryProvider(_telemetryProvider) ? _telemetryProvider : 'noop'; if (parsedProvider === 'custom') { - if (!telemetryProviderConfig) { + if (!_telemetryProviderConfig) { throw new Error( 'TELEMETRY_PROVIDER_CONFIG is required when TELEMETRY_PROVIDER is "custom"', ); } this.telemetry = { provider: 'custom', - providerConfig: providerConfigSchema.parse(JSON.parse(telemetryProviderConfig)), + providerConfig: providerConfigSchema.parse(JSON.parse(_telemetryProviderConfig)), }; } else { this.telemetry = { From 1a8ac307f59e33ea6ef3082c1e3205ce735e1e38 Mon Sep 17 00:00:00 2001 From: Matt Filbert Date: Fri, 23 Jan 2026 14:59:14 -0600 Subject: [PATCH 10/20] @W-20976712: Fix restApiInstance import order --- src/restApiInstance.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/restApiInstance.ts b/src/restApiInstance.ts index 0da7cbec..c488ff9c 100644 --- a/src/restApiInstance.ts +++ b/src/restApiInstance.ts @@ -14,9 +14,9 @@ import { ResponseInterceptorConfig, } from './sdks/tableau/interceptors.js'; import { RestApi } from './sdks/tableau/restApi.js'; -import { Server, userAgent } from './server.js'; import { TableauApiScope } from './server/oauth/scopes.js'; import { TableauAuthInfo } from './server/oauth/schemas.js'; +import { Server, userAgent } from './server.js'; import { isAxiosError } from './utils/axios.js'; import { getExceptionMessage } from './utils/getExceptionMessage.js'; import invariant from './utils/invariant.js'; From 22ea025f88ea44c8c5d602d47d43131ee7ba5789 Mon Sep 17 00:00:00 2001 From: Matt Filbert Date: Fri, 23 Jan 2026 15:23:08 -0600 Subject: [PATCH 11/20] @W-20976712: Fix restApiInstance import sort order --- src/restApiInstance.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/restApiInstance.ts b/src/restApiInstance.ts index c488ff9c..0da7cbec 100644 --- a/src/restApiInstance.ts +++ b/src/restApiInstance.ts @@ -14,9 +14,9 @@ import { ResponseInterceptorConfig, } from './sdks/tableau/interceptors.js'; import { RestApi } from './sdks/tableau/restApi.js'; +import { Server, userAgent } from './server.js'; import { TableauApiScope } from './server/oauth/scopes.js'; import { TableauAuthInfo } from './server/oauth/schemas.js'; -import { Server, userAgent } from './server.js'; import { isAxiosError } from './utils/axios.js'; import { getExceptionMessage } from './utils/getExceptionMessage.js'; import invariant from './utils/invariant.js'; From 6f044f254bab58aa8ede26ab928cef5f1ce29fcc Mon Sep 17 00:00:00 2001 From: Matt Filbert Date: Fri, 23 Jan 2026 15:42:27 -0600 Subject: [PATCH 12/20] @W-20976712: Apply eslint import order for restApiInstance --- src/restApiInstance.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/restApiInstance.ts b/src/restApiInstance.ts index 0da7cbec..ecbcfb4f 100644 --- a/src/restApiInstance.ts +++ b/src/restApiInstance.ts @@ -15,8 +15,8 @@ import { } from './sdks/tableau/interceptors.js'; import { RestApi } from './sdks/tableau/restApi.js'; import { Server, userAgent } from './server.js'; -import { TableauApiScope } from './server/oauth/scopes.js'; import { TableauAuthInfo } from './server/oauth/schemas.js'; +import { TableauApiScope } from './server/oauth/scopes.js'; import { isAxiosError } from './utils/axios.js'; import { getExceptionMessage } from './utils/getExceptionMessage.js'; import invariant from './utils/invariant.js'; From 95a305ddcdaa0f9263bb39ec7a6cd8ea98d19fef Mon Sep 17 00:00:00 2001 From: Matt Filbert Date: Wed, 28 Jan 2026 16:03:51 -0600 Subject: [PATCH 13/20] @W-20976712: Disable scope enforcement in tests --- src/server/oauth/scopes.ts | 4 ++++ tests/testEnv.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/src/server/oauth/scopes.ts b/src/server/oauth/scopes.ts index a748c73b..3e6442bf 100644 --- a/src/server/oauth/scopes.ts +++ b/src/server/oauth/scopes.ts @@ -188,6 +188,10 @@ export function getRequiredScopesForTool(toolName: ToolName): McpScope[] { } export function getRequiredApiScopesForTool(toolName: ToolName): TableauApiScope[] { + if (!getConfig().oauth.enforceScopes) { + return []; + } + return toolScopeMap[toolName].api; } diff --git a/tests/testEnv.ts b/tests/testEnv.ts index 45d57a26..76940beb 100644 --- a/tests/testEnv.ts +++ b/tests/testEnv.ts @@ -14,6 +14,9 @@ export function setEnv(): void { } dotenv.config({ path: 'tests/.env', override: true }); + if (!process.env.OAUTH_DISABLE_SCOPES) { + process.env.OAUTH_DISABLE_SCOPES = 'true'; + } } export function resetEnv(): void { From c855c44bcf3620b2b443da80c05f8efb7bdb4be3 Mon Sep 17 00:00:00 2001 From: Matt Filbert Date: Wed, 28 Jan 2026 16:10:31 -0600 Subject: [PATCH 14/20] @W-20976712: Guard scope lookups when oauth config is missing --- src/server/oauth/scopes.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/server/oauth/scopes.ts b/src/server/oauth/scopes.ts index 3e6442bf..e1f24808 100644 --- a/src/server/oauth/scopes.ts +++ b/src/server/oauth/scopes.ts @@ -180,7 +180,8 @@ export function validateScopes( * @returns Array of required scopes for the endpoint */ export function getRequiredScopesForTool(toolName: ToolName): McpScope[] { - if (!getConfig().oauth.enforceScopes) { + const oauthConfig = getConfig().oauth; + if (!oauthConfig || !oauthConfig.enforceScopes) { return []; } @@ -188,7 +189,8 @@ export function getRequiredScopesForTool(toolName: ToolName): McpScope[] { } export function getRequiredApiScopesForTool(toolName: ToolName): TableauApiScope[] { - if (!getConfig().oauth.enforceScopes) { + const oauthConfig = getConfig().oauth; + if (!oauthConfig || !oauthConfig.enforceScopes) { return []; } From 8acf0ea99d5365a476620041fa5e5cc5219e9011 Mon Sep 17 00:00:00 2001 From: Matt Filbert Date: Tue, 10 Feb 2026 17:35:45 -0600 Subject: [PATCH 15/20] @W-20976712: Apply telemetry cleanup and scope guard Co-authored-by: Cursor --- src/config.ts | 6 +++--- src/restApiInstance.ts | 6 ++---- src/server/oauth/scopes.ts | 5 ----- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/config.ts b/src/config.ts index d433306d..1e737d3d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -271,16 +271,16 @@ export class Config { enforceScopes, }; - const parsedProvider = isTelemetryProvider(_telemetryProvider) ? _telemetryProvider : 'noop'; + const parsedProvider = isTelemetryProvider(telemetryProvider) ? telemetryProvider : 'noop'; if (parsedProvider === 'custom') { - if (!_telemetryProviderConfig) { + if (!telemetryProviderConfig) { throw new Error( 'TELEMETRY_PROVIDER_CONFIG is required when TELEMETRY_PROVIDER is "custom"', ); } this.telemetry = { provider: 'custom', - providerConfig: providerConfigSchema.parse(JSON.parse(_telemetryProviderConfig)), + providerConfig: providerConfigSchema.parse(JSON.parse(telemetryProviderConfig)), }; } else { this.telemetry = { diff --git a/src/restApiInstance.ts b/src/restApiInstance.ts index ecbcfb4f..6074d2e7 100644 --- a/src/restApiInstance.ts +++ b/src/restApiInstance.ts @@ -21,13 +21,11 @@ import { isAxiosError } from './utils/axios.js'; import { getExceptionMessage } from './utils/getExceptionMessage.js'; import invariant from './utils/invariant.js'; -type JwtScopes = TableauApiScope; - const getNewRestApiInstanceAsync = async ( config: Config, requestId: RequestId, server: Server, - jwtScopes: Set, + jwtScopes: Set, signal: AbortSignal, authInfo?: TableauAuthInfo, ): Promise => { @@ -117,7 +115,7 @@ export const useRestApi = async ({ config: Config; requestId: RequestId; server: Server; - jwtScopes: Array; + jwtScopes: Array; signal: AbortSignal; callback: (restApi: RestApi) => Promise; authInfo?: TableauAuthInfo; diff --git a/src/server/oauth/scopes.ts b/src/server/oauth/scopes.ts index e1f24808..1dc15dcb 100644 --- a/src/server/oauth/scopes.ts +++ b/src/server/oauth/scopes.ts @@ -189,11 +189,6 @@ export function getRequiredScopesForTool(toolName: ToolName): McpScope[] { } export function getRequiredApiScopesForTool(toolName: ToolName): TableauApiScope[] { - const oauthConfig = getConfig().oauth; - if (!oauthConfig || !oauthConfig.enforceScopes) { - return []; - } - return toolScopeMap[toolName].api; } From b07bb542feadb141ab1eacf94adee944f5ab9936 Mon Sep 17 00:00:00 2001 From: Matt Filbert Date: Wed, 28 Jan 2026 17:12:34 -0600 Subject: [PATCH 16/20] @W-20976712: axios package added --- package-lock.json | 12 +++++------- package.json | 1 + 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0378ea39..eb565b02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", "@zodios/core": "^10.9.6", + "axios": "^1.13.4", "axios-retry": "^4.5.0", "cors": "^2.8.5", "dotenv": "^16.5.0", @@ -2521,11 +2522,10 @@ } }, "node_modules/axios": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.1.tgz", - "integrity": "sha512-Kn4kbSXpkFHCGE6rBFNwIv0GQs4AvDT80jlveJDKFxjbTYMUeB4QtsdPCv6H8Cm19Je7IU6VFtRl2zWZI0rudQ==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", + "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", @@ -3814,7 +3814,6 @@ "url": "https://github.com/sponsors/RubenVerborgh" } ], - "peer": true, "engines": { "node": ">=4.0" }, @@ -5816,8 +5815,7 @@ "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "peer": true + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, "node_modules/punycode": { "version": "2.3.1", diff --git a/package.json b/package.json index c345912b..0b1af2fc 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", "@zodios/core": "^10.9.6", + "axios": "^1.13.4", "axios-retry": "^4.5.0", "cors": "^2.8.5", "dotenv": "^16.5.0", From acd29c98275f191152ac1167ff6a8e7c43ed9e52 Mon Sep 17 00:00:00 2001 From: Matt Filbert Date: Wed, 28 Jan 2026 17:18:18 -0600 Subject: [PATCH 17/20] @W-20976712: Add axios dependency for shared client --- src/utils/axios.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/utils/axios.ts b/src/utils/axios.ts index de5c6824..f2c3edec 100644 --- a/src/utils/axios.ts +++ b/src/utils/axios.ts @@ -1,8 +1,4 @@ -import axios, { - AxiosRequestConfig, - AxiosResponse, - isAxiosError, -} from '../../node_modules/axios/index.js'; +import axios, { AxiosRequestConfig, AxiosResponse, isAxiosError } from 'axios'; export function getStringResponseHeader( headers: AxiosResponse['headers'], @@ -15,7 +11,5 @@ export function getStringResponseHeader( return ''; } -// Our dependency on Axios is indirect through Zodios. -// Zodios doesn't re-export the exports of axios, so we need to import it haphazardly through node_modules. -// This re-export is only to prevent import clutter in the codebase. +// We re-export Axios types to avoid import clutter in the codebase. export { axios, AxiosRequestConfig, AxiosResponse, isAxiosError }; From 08c756003851d8ccd8a96211a9a52c5f61f655df Mon Sep 17 00:00:00 2001 From: Matt Filbert Date: Tue, 10 Feb 2026 17:47:54 -0600 Subject: [PATCH 18/20] @W-20976712: Remove axios dep and fix lint Drop explicit axios dependency now that build/tests pass via transitive usage, and reorder imports to satisfy simple-import-sort. Co-authored-by: Cursor --- package-lock.json | 6 ++++-- package.json | 1 - src/tools/queryDatasource/queryDatasource.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index eb565b02..d8a27c65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", "@zodios/core": "^10.9.6", - "axios": "^1.13.4", "axios-retry": "^4.5.0", "cors": "^2.8.5", "dotenv": "^16.5.0", @@ -2526,6 +2525,7 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", @@ -3814,6 +3814,7 @@ "url": "https://github.com/sponsors/RubenVerborgh" } ], + "peer": true, "engines": { "node": ">=4.0" }, @@ -5815,7 +5816,8 @@ "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "peer": true }, "node_modules/punycode": { "version": "2.3.1", diff --git a/package.json b/package.json index 0b1af2fc..c345912b 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,6 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", "@zodios/core": "^10.9.6", - "axios": "^1.13.4", "axios-retry": "^4.5.0", "cors": "^2.8.5", "dotenv": "^16.5.0", diff --git a/src/tools/queryDatasource/queryDatasource.ts b/src/tools/queryDatasource/queryDatasource.ts index 292f30ef..d13d4fcf 100644 --- a/src/tools/queryDatasource/queryDatasource.ts +++ b/src/tools/queryDatasource/queryDatasource.ts @@ -13,8 +13,8 @@ import { } from '../../sdks/tableau/apis/vizqlDataServiceApi.js'; import { Server } from '../../server.js'; import { getTableauAuthInfo } from '../../server/oauth/getTableauAuthInfo.js'; -import { getRequiredApiScopesForTool } from '../../server/oauth/scopes.js'; import { TableauAuthInfo } from '../../server/oauth/schemas.js'; +import { getRequiredApiScopesForTool } from '../../server/oauth/scopes.js'; import { getSiteLuidFromAccessToken } from '../../utils/getSiteLuidFromAccessToken.js'; import { getResultForTableauVersion } from '../../utils/isTableauVersionAtLeast.js'; import { Provider } from '../../utils/provider.js'; From 23363ff201beb7742dfb64394db1d6f91cb355f8 Mon Sep 17 00:00:00 2001 From: Matt Filbert Date: Tue, 10 Feb 2026 17:56:38 -0600 Subject: [PATCH 19/20] @W-20976712: Fix pulse metrics scope mapping Use the pulse definitions scope for listing metrics by definition id to avoid 403s. Co-authored-by: Cursor --- src/server/oauth/scopes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/oauth/scopes.ts b/src/server/oauth/scopes.ts index 1dc15dcb..69af33f9 100644 --- a/src/server/oauth/scopes.ts +++ b/src/server/oauth/scopes.ts @@ -103,7 +103,7 @@ const toolScopeMap: Record Date: Tue, 10 Feb 2026 18:07:09 -0600 Subject: [PATCH 20/20] @W-20976712: Align plan with no token exchange Remove token exchange tasks and questions from the plan, noting it is out of scope for initial release. Co-authored-by: Cursor --- OAUTH_IMPLEMENTATION_PLAN.md | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/OAUTH_IMPLEMENTATION_PLAN.md b/OAUTH_IMPLEMENTATION_PLAN.md index cec4a9ab..d6fbe1f0 100644 --- a/OAUTH_IMPLEMENTATION_PLAN.md +++ b/OAUTH_IMPLEMENTATION_PLAN.md @@ -94,10 +94,10 @@ This document outlines the phased approach to integrate Tableau OAuth with the M --- -## Phase 2: Scope Mapping & Token Exchange (Coordination with Auth Team) +## Phase 2: Scope Mapping (Coordination with Auth Team) **Timeline**: 3-4 weeks -**Dependencies**: Auth team decisions on scope mapping and token exchange mechanism +**Dependencies**: Auth team decisions on scope mapping ### 2.1 Scope Mapping Design & Implementation **Coordination needed with George:** @@ -113,19 +113,9 @@ This document outlines the phased approach to integrate Tableau OAuth with the M - [ ] Integrate scope mapping into authorize endpoint (when forwarding to Tableau OAuth) - [ ] Integrate scope mapping into token exchange -### 2.2 Token Exchange Implementation -**Coordination needed with Auth team:** -- [ ] Understand Tableau OAuth token format and requirements -- [ ] Determine token exchange endpoint and mechanism -- [ ] Understand how to convert MCP tokens to Tableau REST API-compatible tokens -- [ ] Define token refresh strategy - -**Implementation tasks:** -- [ ] Implement token exchange logic -- [ ] Update token endpoint to handle Tableau token exchange -- [ ] Ensure exchanged tokens work with Tableau REST APIs -- [ ] Implement token refresh flow for exchanged tokens -- [ ] Add error handling for token exchange failures +### 2.2 Token Exchange +Token exchange is **out of scope** for the initial release. Revisit only if the authorization +strategy changes or Tableau OAuth requirements evolve. ### 2.3 Authorization Flow Updates - [ ] Update authorize endpoint to include mapped scopes in Tableau OAuth redirect @@ -136,8 +126,6 @@ This document outlines the phased approach to integrate Tableau OAuth with the M ### 2.4 Testing - [ ] Integration tests with Tableau OAuth - [ ] Test scope mapping in various scenarios -- [ ] Test token exchange end-to-end -- [ ] Test token refresh with exchanged tokens - [ ] Test with actual Tableau REST API calls --- @@ -262,10 +250,8 @@ oauth: { 2. **Scope Mapping**: How should we map MCP scopes to Tableau scopes? One-to-one, one-to-many, or many-to-one? -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? +3. **Token Exchange**: + - Not planned for initial release. Revisit if requirements change. 4. **Scope Lifecycle**: - How do we add new scopes without breaking existing clients?