Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,433 changes: 1,356 additions & 77 deletions package-lock.json

Large diffs are not rendered by default.

14 changes: 9 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,16 @@
},
"scripts": {
"clean": "npx rimraf ./build",
":build": "esbuild src/index.ts --bundle --platform=node --format=cjs --minify --outfile=build/index.js --sourcemap --log-override:empty-import-meta=silent",
":build:dev": "esbuild src/index.ts --bundle --packages=external --platform=node --format=cjs --outfile=build/index.js --sourcemap",
":build": "tsx src/scripts/build.ts",
":build:dev": "tsx src/scripts/build.ts --dev",
":build:watch": "tsx src/scripts/build.ts --dev --watch",
"build": "run-s clean :build exec-perms",
"build:dev": "run-s clean :build:dev exec-perms",
"build:watch": "npm run :build:dev -- --watch",
"build:watch": "run-s clean :build:watch",
"build:docker": "docker build -t tableau-mcp .",
":build:mcpb": "npx -y @anthropic-ai/mcpb pack . tableau-mcp.mcpb",
"build:mcpb": "run-s build:manifest:script build:manifest :build:mcpb",
"build:manifest": "node build/scripts/createClaudeMcpBundleManifest.mjs",
"build:manifest:script": "esbuild src/scripts/createClaudeMcpBundleManifest.ts --bundle --platform=node --format=esm --outdir=build/scripts --sourcemap=inline --out-extension:.js=.mjs",
"build:manifest": "tsx src/scripts/createClaudeMcpBundleManifest.ts",
"start:http": "node build/index.js",
"start:http:docker": "docker run -p 3927:3927 -i --rm --env-file env.list tableau-mcp",
"lint": "npm exec eslint",
Expand Down Expand Up @@ -60,6 +60,7 @@
"express": "^5.1.0",
"fast-levenshtein": "^3.0.0",
"jose": "^6.0.12",
"puppeteer": "^24.23.0",
"ssrfcheck": "^1.2.0",
"ts-results-es": "^5.0.1",
"zod": "^3.24.3",
Expand All @@ -71,6 +72,7 @@
"@eslint/js": "^9.25.1",
"@modelcontextprotocol/inspector": "^0.16.6",
"@openai/agents": "^0.1.9",
"@tableau/embedding-api": "^3.14.2",
"@types/cors": "^2.8.19",
"@types/eslint__js": "^8.42.3",
"@types/express": "^5.0.3",
Expand All @@ -80,6 +82,7 @@
"@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1",
"@vitest/coverage-v8": "^3.1.3",
"chokidar": "^5.0.0",
"esbuild": "^0.25.5",
"eslint": "^9.25.1",
"eslint-config-prettier": "^10.1.2",
Expand All @@ -91,6 +94,7 @@
"rimraf": "^6.0.1",
"shx": "^0.4.0",
"supertest": "^7.1.4",
"tsx": "^4.21.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.31.1",
"vitest": "^3.1.3"
Expand Down
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export class Config {
serverLogDirectory: string;
boundedContext: BoundedContext;
tableauServerVersionCheckIntervalInHours: number;
useHeadedBrowser: boolean;
oauth: {
enabled: boolean;
issuer: string;
Expand Down Expand Up @@ -120,6 +121,7 @@ export class Config {
INCLUDE_DATASOURCE_IDS: includeDatasourceIds,
INCLUDE_WORKBOOK_IDS: includeWorkbookIds,
TABLEAU_SERVER_VERSION_CHECK_INTERVAL_IN_HOURS: tableauServerVersionCheckIntervalInHours,
USE_HEADED_BROWSER: useHeadedBrowser,
DANGEROUSLY_DISABLE_OAUTH: disableOauth,
OAUTH_ISSUER: oauthIssuer,
OAUTH_JWE_PRIVATE_KEY: oauthJwePrivateKey,
Expand Down Expand Up @@ -153,6 +155,7 @@ export class Config {
disableQueryDatasourceValidationRequests === 'true';
this.disableMetadataApiRequests = disableMetadataApiRequests === 'true';
this.disableSessionManagement = disableSessionManagement === 'true';
this.useHeadedBrowser = useHeadedBrowser === 'true';
this.enableServerLogging = enableServerLogging === 'true';
this.serverLogDirectory = serverLogDirectory || join(__dirname, 'logs');
this.boundedContext = {
Expand Down
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
#!/usr/bin/env node
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import dotenv from 'dotenv';
import express from 'express';

import { getConfig } from './config.js';
import { isLoggingLevel, log, setLogLevel, setServerLogger, writeToStderr } from './logging/log.js';
import { ServerLogger } from './logging/serverLogger.js';
import { Server, serverName, serverVersion } from './server.js';
import { startExpressServer } from './server/express.js';
import { setupUiRoutes } from './server/ui/views/routes.js';
import { getExceptionMessage } from './utils/getExceptionMessage.js';

async function startServer(): Promise<void> {
Expand All @@ -24,6 +26,13 @@ async function startServer(): Promise<void> {
await server.registerTools();
server.registerRequestHandlers();

const app = express();
setupUiRoutes(app);

app.listen(config.httpPort, () => {
log.info(server, `Embed server running on port ${config.httpPort}`);
});

const transport = new StdioServerTransport();
await server.connect(transport);

Expand Down
36 changes: 19 additions & 17 deletions src/restApiInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Server, userAgent } from './server.js';
import { TableauAuthInfo } from './server/oauth/schemas.js';
import { isAxiosError } from './utils/axios.js';
import { getExceptionMessage } from './utils/getExceptionMessage.js';
import { getJwtAdditionalPayload, getJwtUsername } from './utils/getTableauAccessTokens.js';
import invariant from './utils/invariant.js';

type JwtScopes =
Expand All @@ -30,6 +31,13 @@ type JwtScopes =
| 'tableau:views:download'
| 'tableau:insight_brief:create';

export type RestApiArgs = {
config: Config;
requestId: RequestId;
server: Server;
signal: AbortSignal;
};

const getNewRestApiInstanceAsync = async (
config: Config,
requestId: RequestId,
Expand Down Expand Up @@ -81,25 +89,25 @@ const getNewRestApiInstanceAsync = async (
await restApi.signIn({
type: 'direct-trust',
siteName: config.siteName,
username: getJwtUsername(config, authInfo),
username: getJwtUsername(config.jwtUsername, authInfo),
clientId: config.connectedAppClientId,
secretId: config.connectedAppSecretId,
secretValue: config.connectedAppSecretValue,
scopes: jwtScopes,
additionalPayload: getJwtAdditionalPayload(config, authInfo),
additionalPayload: getJwtAdditionalPayload(config.jwtAdditionalPayload, authInfo),
});
} else if (config.auth === 'uat') {
await restApi.signIn({
type: 'uat',
siteName: config.siteName,
username: getJwtUsername(config, authInfo),
username: getJwtUsername(config.jwtUsername, authInfo),
tenantId: config.uatTenantId,
issuer: config.uatIssuer,
usernameClaimName: config.uatUsernameClaimName,
privateKey: config.uatPrivateKey,
keyId: config.uatKeyId,
scopes: jwtScopes,
additionalPayload: getJwtAdditionalPayload(config, authInfo),
additionalPayload: getJwtAdditionalPayload(config.jwtAdditionalPayload, authInfo),
});
} else {
if (!authInfo?.accessToken || !authInfo?.userId) {
Expand All @@ -120,6 +128,7 @@ export const useRestApi = async <T>({
jwtScopes,
signal,
authInfo,
options,
}: {
config: Config;
requestId: RequestId;
Expand All @@ -128,7 +137,12 @@ export const useRestApi = async <T>({
signal: AbortSignal;
callback: (restApi: RestApi) => Promise<T>;
authInfo?: TableauAuthInfo;
options?: Partial<{
bypassSignOut: boolean;
}>;
}): Promise<T> => {
const { bypassSignOut = false } = options ?? {};

const restApi = await getNewRestApiInstanceAsync(
config,
requestId,
Expand All @@ -140,7 +154,7 @@ export const useRestApi = async <T>({
try {
return await callback(restApi);
} finally {
if (config.auth !== 'oauth') {
if (config.auth !== 'oauth' && !bypassSignOut) {
// Tableau REST sessions for 'pat' and 'direct-trust' are intentionally ephemeral.
// Sessions for 'oauth' are not. Signing out would invalidate the session,
// preventing the access token from being reused for subsequent requests.
Expand Down Expand Up @@ -272,15 +286,3 @@ function getUserAgent(server: Server): string {
}
return userAgentParts.join(' ');
}

function getJwtUsername(config: Config, authInfo: TableauAuthInfo | undefined): string {
return config.jwtUsername.replaceAll('{OAUTH_USERNAME}', authInfo?.username ?? '');
}

function getJwtAdditionalPayload(
config: Config,
authInfo: TableauAuthInfo | undefined,
): Record<string, unknown> {
const json = config.jwtAdditionalPayload.replaceAll('{OAUTH_USERNAME}', authInfo?.username ?? '');
return JSON.parse(json || '{}');
}
77 changes: 77 additions & 0 deletions src/scripts/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/* eslint-disable no-console */
import chokidar from 'chokidar';
import { context } from 'esbuild';
import { cpSync, statSync } from 'fs';
import { extname, relative } from 'path';

const dev = process.argv.includes('--dev');
const watch = process.argv.includes('--watch');

const staticAssets = [
{ source: './src/server/ui/views', destination: './build/server/ui/views', ext: '.html' },
];

(async () => {
const ctx = await context({
entryPoints: ['./src/index.ts'],
bundle: true,
platform: 'node',
format: 'cjs',
minify: !dev,
packages: dev ? 'external' : 'bundle',
sourcemap: true,
logLevel: dev ? 'debug' : 'info',
logOverride: {
'empty-import-meta': 'silent',
},
outfile: './build/index.js',
});

await ctx.rebuild();
for (const { source, destination, ext } of staticAssets) {
copyStaticAssets(source, destination, ext);

if (!watch) {
await ctx.dispose();
return;
}

const watcher = chokidar.watch(source, {
ignored: (path, stats) => !!(stats?.isFile() && !path.endsWith('.html')),
ignoreInitial: true,
persistent: true,
});

watcher.on('all', (event, path) => {
const relativePath = relative(source, path);
console.log(
`📄 ${event}: ${relative('./src/server', path)} -> ${destination}/${relativePath}`,
);
cpSync(path, `${destination}/${relativePath}`);
});
}

process.on('SIGINT', async () => {
await ctx.dispose();
process.exit(0);
});
})();

function copyStaticAssets(source: string, destination: string, ext: string): void {
cpSync(source, destination, {
recursive: true,
filter: (source) => {
if (statSync(source).isDirectory()) {
return true;
}

if (extname(source) === ext) {
const relativePath = relative('./src/server', source);
console.log(`📄 ${relativePath} -> ./build/server/${relativePath}`);
return true;
}

return false;
},
});
}
8 changes: 8 additions & 0 deletions src/scripts/createClaudeMcpBundleManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,14 @@ const envVars = {
required: false,
sensitive: false,
},
USE_HEADED_BROWSER: {
includeInUserConfig: false,
type: 'boolean',
title: 'Use Headed Browser',
description: 'Use a headed browser.',
required: false,
sensitive: false,
},
DANGEROUSLY_DISABLE_OAUTH: {
includeInUserConfig: false,
type: 'boolean',
Expand Down
2 changes: 1 addition & 1 deletion src/sdks/tableau/restApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export class RestApi {
this._responseInterceptor = options.responseInterceptor;
}

private get creds(): Credentials {
get creds(): Credentials {
if (!this._creds) {
throw new Error('No credentials found. Authenticate by calling signIn() first.');
}
Expand Down
3 changes: 3 additions & 0 deletions src/server/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { getTableauAuthInfo } from './oauth/getTableauAuthInfo.js';
import { OAuthProvider } from './oauth/provider.js';
import { TableauAuthInfo } from './oauth/schemas.js';
import { AuthenticatedRequest } from './oauth/types.js';
import { setupUiRoutes } from './ui/views/routes.js';

const SESSION_ID_HEADER = 'mcp-session-id';

Expand Down Expand Up @@ -73,6 +74,8 @@ export async function startExpressServer({
config.disableSessionManagement ? methodNotAllowed : handleSessionRequest,
);

setupUiRoutes(app);

const useSsl = !!(config.sslKey && config.sslCert);
if (!useSsl) {
return new Promise((resolve) => {
Expand Down
Loading
Loading