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
814 changes: 767 additions & 47 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
},
"scripts": {
":build": "npx rimraf ./build && esbuild src/index.ts --bundle --packages=external --platform=node --format=esm --minify --outdir=build --sourcemap",
":build:dev": "npx rimraf ./build && esbuild src/index.ts --bundle --packages=external --platform=node --format=esm --outdir=build --sourcemap",
"build": "run-s :build exec-perms",
"build:watch": "npm run :build -- --watch",
"build:docker": "docker build -t tableau-mcp .",
Expand Down Expand Up @@ -54,6 +55,7 @@
"express": "^5.1.0",
"fast-levenshtein": "^3.0.0",
"jose": "^6.0.12",
"puppeteer": "^24.23.0",
"ts-results-es": "^5.0.1",
"zod": "^3.24.3",
"zod-validation-error": "^4.0.1"
Expand All @@ -63,6 +65,7 @@
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.25.1",
"@modelcontextprotocol/inspector": "^0.16.6",
"@tableau/embedding-api": "^3.14.2",
"@types/cors": "^2.8.19",
"@types/eslint__js": "^8.42.3",
"@types/express": "^5.0.3",
Expand Down
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export class Config {
maxResultLimit: number | null;
disableQueryDatasourceFilterValidation: boolean;
disableMetadataApiRequests: boolean;
useHeadedBrowser: boolean;

constructor() {
const cleansedVars = removeClaudeMcpBundleUserConfigTemplates(process.env);
Expand Down Expand Up @@ -58,6 +59,7 @@ export class Config {
MAX_RESULT_LIMIT: maxResultLimit,
DISABLE_QUERY_DATASOURCE_FILTER_VALIDATION: disableQueryDatasourceFilterValidation,
DISABLE_METADATA_API_REQUESTS: disableMetadataApiRequests,
USE_HEADED_BROWSER: useHeadedBrowser,
} = cleansedVars;

const defaultPort = 3927;
Expand All @@ -76,6 +78,7 @@ export class Config {
this.disableLogMasking = disableLogMasking === 'true';
this.disableQueryDatasourceFilterValidation = disableQueryDatasourceFilterValidation === 'true';
this.disableMetadataApiRequests = disableMetadataApiRequests === 'true';
this.useHeadedBrowser = useHeadedBrowser === 'true';

const maxResultLimitNumber = maxResultLimit ? parseInt(maxResultLimit) : NaN;
this.maxResultLimit =
Expand Down
13 changes: 13 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
#!/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, writeToStderr } from './logging/log.js';
import { Server, serverName, serverVersion } from './server.js';
import { startExpressServer } from './server/express.js';
import { embedHtml } from './tools/views/embed.html.js';
import { getExceptionMessage } from './utils/getExceptionMessage.js';

async function startServer(): Promise<void> {
Expand All @@ -20,6 +22,17 @@ async function startServer(): Promise<void> {
server.registerTools();
server.registerRequestHandlers();

const app = express();

app.get('/embed', (req, res) => {
res.set('Content-Type', 'text/html');
res.send(Buffer.from(embedHtml));
});

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

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

Expand Down
10 changes: 1 addition & 9 deletions src/restApiInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import RestApi from './sdks/tableau/restApi.js';
import { Server } from './server.js';
import { getExceptionMessage } from './utils/getExceptionMessage.js';
import { getJwtAdditionalPayload, getJwtSubClaim } from './utils/getJwt.js';
import { isAxiosError } from './utils/isAxiosError.js';

type JwtScopes =
Expand Down Expand Up @@ -196,12 +197,3 @@ function logResponse(

log.info(server, messageObj, { logger: 'rest-api', requestId });
}

function getJwtSubClaim(config: Config): string {
return config.jwtSubClaim;
}

function getJwtAdditionalPayload(config: Config): Record<string, unknown> {
const json = config.jwtAdditionalPayload;
return JSON.parse(json || '{}');
}
5 changes: 5 additions & 0 deletions src/server/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import https from 'https';
import { Config } from '../config.js';
import { setLogLevel } from '../logging/log.js';
import { Server } from '../server.js';
import { embedHtml } from '../tools/views/embed.html.js';

export async function startExpressServer({
basePath,
Expand Down Expand Up @@ -43,6 +44,10 @@ export async function startExpressServer({
app.post(path, createMcpServer);
app.get(path, methodNotAllowed);
app.delete(path, methodNotAllowed);
app.get('/embed', (req, res) => {
res.set('Content-Type', 'text/html');
res.send(Buffer.from(embedHtml));
});

const useSsl = !!(config.sslKey && config.sslCert);
if (!useSsl) {
Expand Down
3 changes: 3 additions & 0 deletions src/tools/toolName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const toolNames = [
'list-pulse-metric-subscriptions',
'generate-pulse-metric-value-insight-bundle',
'search-content',
'embed-workbook',
] as const;
export type ToolName = (typeof toolNames)[number];

Expand All @@ -23,6 +24,7 @@ export const toolGroupNames = [
'view',
'pulse',
'content-exploration',
'embedding',
] as const;
export type ToolGroupName = (typeof toolGroupNames)[number];

Expand All @@ -39,6 +41,7 @@ export const toolGroups = {
'generate-pulse-metric-value-insight-bundle',
],
'content-exploration': ['search-content'],
embedding: ['embed-workbook'],
} as const satisfies Record<ToolGroupName, Array<ToolName>>;

export function isToolName(value: unknown): value is ToolName {
Expand Down
82 changes: 82 additions & 0 deletions src/tools/views/embed.html.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
export const embedHtml = String.raw`<html>

<head>
<style>
html, body, tableau-viz, tableau-authoring-viz, tableau-pulse {
height: 100%;
width: 100%;
}
</style>
</head>

<body>
<tableau-viz id="viz"></tableau-viz>
<script type="module">
function showSuccess(message) {
const div = document.createElement('div');
div.id = 'success';
div.textContent = message;
div.style.display = 'none';
document.body.prepend(div);
}

function showError(message) {
const div = document.createElement('div');
div.id = 'error';
div.textContent = message;
div.style.display = 'none';
document.body.prepend(div);
}

function getExceptionMessage(error) {
if (typeof error === 'string') {
return error;
}

if (error instanceof Error) {
return error.message;
}

try {
return JSON.stringify(error) ?? 'undefined';
} catch {
return ${'`${error}`'};
}
}

const urlParams = new URLSearchParams(window.location.hash.substring(1));
const url = urlParams.get('url');
const token = urlParams.get('token');
const parsedUrl = new URL(url);

(async () => {
const { TableauEventType } = await import(${'`${parsedUrl.origin}'}/javascripts/api/tableau.embedding.3.latest.js${'`'});

viz.token = token;
viz.src = parsedUrl.toString();
document.body.appendChild(viz);

try {
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject('firstinteractive event did not fire within 30 seconds'), 30000);

viz.addEventListener(TableauEventType.FirstInteractive, () => {
showSuccess('Viz is interactive!');
clearTimeout(timeout);
resolve();
});

viz.addEventListener(TableauEventType.VizLoadError, (e) => {
const detail = JSON.parse(e.detail.message);
clearTimeout(timeout);
reject(JSON.stringify({ status: detail.statusCode, errorCodes: JSON.parse(detail.errorMessage).result.errors.map(({ code }) => code) }));
});
});
} catch (e) {
showError(getExceptionMessage(e));
}
})();
</script>
</body>

</html>`;
Loading
Loading