/**
* Tableau MCP Server - Phase 3 Implementation
*
* Implements:
* - SSE (Server-Sent Events) transport for remote MCP access
* - X-API-Key authentication middleware
* - MCP server initialization and tool registration
* - CORS configuration for Cursor access
* - Structured logging with request tracking
* - Enhanced health checks with dependency validation
* - Comprehensive error handling
*/
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import {
ListToolsRequestSchema,
CallToolRequestSchema,
ErrorCode,
McpError
} from '@modelcontextprotocol/sdk/types.js';
import { TableauClient } from './tableau-client.js';
// Import all tool handlers
import { listWorkbooksHandler, listWorkbooksTool } from './tools/list-workbooks.js';
import { listViewsHandler, listViewsTool } from './tools/list-views.js';
import { queryViewHandler, queryViewTool } from './tools/query-view.js';
import { refreshExtractHandler, refreshExtractTool } from './tools/refresh-extract.js';
import { searchContentHandler, searchContentTool } from './tools/search-content.js';
import { getMetadataHandler, getMetadataTool } from './tools/get-metadata.js';
// Phase 5: Advanced tools
import { getDashboardFiltersHandler, getDashboardFiltersTool } from './tools/get-dashboard-filters.js';
import { exportDashboardPDFHandler, exportDashboardPDFTool } from './tools/export-dashboard-pdf.js';
import { exportDashboardPPTXHandler, exportDashboardPPTXTool } from './tools/export-dashboard-pptx.js';
// Load environment variables
dotenv.config();
// ============================================================================
// CONFIGURATION & VALIDATION
// ============================================================================
const PORT = process.env.PORT || 8080;
const API_KEY = process.env.MCP_API_KEY;
const NODE_ENV = process.env.NODE_ENV || 'development';
// Validate required environment variables
if (!API_KEY || API_KEY.length < 32) {
console.error('ERROR: MCP_API_KEY must be set and at least 32 characters long');
process.exit(1);
}
// ============================================================================
// LOGGING SYSTEM
// ============================================================================
interface LogEntry {
timestamp: string;
level: 'info' | 'warn' | 'error';
message: string;
requestId?: string;
method?: string;
path?: string;
statusCode?: number;
duration?: number;
error?: any;
}
class Logger {
private static sanitize(obj: any): any {
if (typeof obj === 'string') {
// Sanitize potential API keys and tokens
return obj.replace(/([a-zA-Z0-9]{32,})/g, '[REDACTED]');
}
if (typeof obj === 'object' && obj !== null) {
const sanitized: any = Array.isArray(obj) ? [] : {};
for (const key in obj) {
if (key.toLowerCase().includes('key') ||
key.toLowerCase().includes('token') ||
key.toLowerCase().includes('password') ||
key.toLowerCase().includes('secret')) {
sanitized[key] = '[REDACTED]';
} else {
sanitized[key] = this.sanitize(obj[key]);
}
}
return sanitized;
}
return obj;
}
private static log(entry: LogEntry): void {
const sanitizedEntry = this.sanitize(entry);
console.log(JSON.stringify(sanitizedEntry));
}
static info(message: string, meta?: any): void {
this.log({
timestamp: new Date().toISOString(),
level: 'info',
message,
...meta,
});
}
static warn(message: string, meta?: any): void {
this.log({
timestamp: new Date().toISOString(),
level: 'warn',
message,
...meta,
});
}
static error(message: string, error?: any, meta?: any): void {
this.log({
timestamp: new Date().toISOString(),
level: 'error',
message,
error: error ? {
message: error.message,
stack: error.stack,
...error,
} : undefined,
...meta,
});
}
}
// ============================================================================
// EXPRESS APP SETUP
// ============================================================================
const app = express();
// Track server start time for uptime calculation
const serverStartTime = Date.now();
// ============================================================================
// CORS CONFIGURATION
// ============================================================================
const corsOptions = {
origin: (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
// Allow requests with no origin (like Postman, curl, etc.)
if (!origin) return callback(null, true);
// Allow Cursor domains and localhost for development
const allowedOrigins = [
'https://cursor.sh',
'https://www.cursor.sh',
/^https:\/\/.*\.cursor\.sh$/,
/^http:\/\/localhost(:\d+)?$/,
/^http:\/\/127\.0\.0\.1(:\d+)?$/,
];
const isAllowed = allowedOrigins.some(pattern => {
if (typeof pattern === 'string') {
return origin === pattern;
}
return pattern.test(origin);
});
if (isAllowed) {
callback(null, true);
} else {
Logger.warn('CORS request from disallowed origin', { origin });
callback(null, false);
}
},
credentials: false, // We use API key authentication, not cookies
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'X-API-Key', 'Accept'],
exposedHeaders: [],
maxAge: 86400, // 24 hours
};
app.use(cors(corsOptions));
// ============================================================================
// MIDDLEWARE
// ============================================================================
// Body parser middleware
app.use(express.json());
// Request logging middleware
app.use((req: Request, res: Response, next: NextFunction) => {
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const startTime = Date.now();
// Add requestId to request for tracking
(req as any).requestId = requestId;
// Log request
Logger.info('Incoming request', {
requestId,
method: req.method,
path: req.path,
query: req.query,
headers: {
'user-agent': req.headers['user-agent'],
'origin': req.headers.origin,
},
});
// Log response when finished
res.on('finish', () => {
const duration = Date.now() - startTime;
Logger.info('Request completed', {
requestId,
method: req.method,
path: req.path,
statusCode: res.statusCode,
duration,
});
});
next();
});
// ============================================================================
// AUTHENTICATION MIDDLEWARE
// ============================================================================
/**
* Validates X-API-Key header for authentication
* Uses constant-time comparison to prevent timing attacks
*/
function authenticateAPIKey(req: Request, res: Response, next: NextFunction): void {
const providedKey = req.headers['x-api-key'] as string;
if (!providedKey) {
Logger.warn('Authentication failed: Missing API key', {
requestId: (req as any).requestId,
path: req.path,
});
res.status(401).json({
error: {
message: 'Missing API key. Please provide X-API-Key header.',
code: 'MISSING_API_KEY',
statusCode: 401,
timestamp: new Date().toISOString(),
},
});
return;
}
// Constant-time comparison to prevent timing attacks
if (providedKey.length !== API_KEY!.length || providedKey !== API_KEY) {
Logger.warn('Authentication failed: Invalid API key', {
requestId: (req as any).requestId,
path: req.path,
});
res.status(401).json({
error: {
message: 'Invalid API key.',
code: 'INVALID_API_KEY',
statusCode: 401,
timestamp: new Date().toISOString(),
},
});
return;
}
Logger.info('Authentication successful', {
requestId: (req as any).requestId,
});
next();
}
// ============================================================================
// MCP SERVER SETUP
// ============================================================================
/**
* Initialize MCP server with capabilities
*/
const mcpServer = new Server(
{
name: 'tableau-mcp-server',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);
/**
* Tool registry with all 9 MCP tools (6 core + 3 advanced)
*/
const toolRegistry = [
// Core tools (Phase 4)
listWorkbooksTool,
listViewsTool,
queryViewTool,
refreshExtractTool,
searchContentTool,
getMetadataTool,
// Advanced tools (Phase 5)
getDashboardFiltersTool,
exportDashboardPDFTool,
exportDashboardPPTXTool,
];
/**
* Create Tableau client instance for tools to use
* This will be initialized on each request to ensure fresh authentication
*/
function createTableauClient(): TableauClient {
// Validate Tableau configuration
const requiredEnvVars = [
'TABLEAU_SERVER_URL',
'TABLEAU_SITE_ID',
'TABLEAU_TOKEN_NAME',
'TABLEAU_TOKEN_VALUE',
];
const missing = requiredEnvVars.filter(envVar => !process.env[envVar]);
if (missing.length > 0) {
throw new Error(`Missing required Tableau environment variables: ${missing.join(', ')}`);
}
return new TableauClient({
serverUrl: process.env.TABLEAU_SERVER_URL!,
siteId: process.env.TABLEAU_SITE_ID!,
tokenName: process.env.TABLEAU_TOKEN_NAME!,
tokenValue: process.env.TABLEAU_TOKEN_VALUE!,
apiVersion: process.env.TABLEAU_API_VERSION || '3.21',
});
}
/**
* Register all tools with the MCP server
* Implements Phase 4: Core MCP Tools (6 tools)
* Implements Phase 5: Advanced MCP Tools (3 tools)
*/
function registerTools(): void {
Logger.info('Registering MCP tools', { count: toolRegistry.length });
// Handler for listing available tools
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => {
Logger.info('ListTools request received');
return {
tools: toolRegistry.map(tool => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
})),
};
});
// Handler for tool execution
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
const toolName = request.params.name;
const args = request.params.arguments || {};
Logger.info('CallTool request received', {
tool: toolName,
hasArgs: Object.keys(args).length > 0,
});
try {
// Create Tableau client for this request
const tableauClient = createTableauClient();
// Authenticate with Tableau
await tableauClient.authenticate();
// Route to appropriate tool handler
switch (toolName) {
// Core tools
case 'tableau_list_workbooks':
return await listWorkbooksHandler(args as any, tableauClient);
case 'tableau_list_views':
return await listViewsHandler(args as any, tableauClient);
case 'tableau_query_view':
return await queryViewHandler(args as any, tableauClient);
case 'tableau_refresh_extract':
return await refreshExtractHandler(args as any, tableauClient);
case 'tableau_search_content':
return await searchContentHandler(args as any, tableauClient);
case 'tableau_get_metadata':
return await getMetadataHandler(args as any, tableauClient);
// Advanced tools (Phase 5)
case 'tableau_get_dashboard_filters':
return await getDashboardFiltersHandler(args as any, tableauClient);
case 'tableau_export_dashboard_pdf':
return await exportDashboardPDFHandler(args as any, tableauClient);
case 'tableau_export_dashboard_pptx':
return await exportDashboardPPTXHandler(args as any, tableauClient);
default:
Logger.warn('Unknown tool requested', { tool: toolName });
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${toolName}`
);
}
} catch (error: any) {
Logger.error('Tool execution failed', error, {
tool: toolName,
args: Object.keys(args),
});
// If it's already an MCP error, rethrow it
if (error instanceof McpError) {
throw error;
}
// Convert other errors to MCP errors
throw new McpError(
ErrorCode.InternalError,
`Tool execution failed: ${error.message}`
);
}
});
Logger.info('Tool registration complete', {
toolCount: toolRegistry.length,
tools: toolRegistry.map(t => t.name),
});
}
// Initialize tool registration
registerTools();
// ============================================================================
// HEALTH CHECK ENDPOINTS
// ============================================================================
/**
* Basic health check - always responds if server is running
*/
app.get('/health', (req: Request, res: Response) => {
res.json({
status: 'healthy',
service: 'tableau-mcp-server',
version: '1.0.0',
timestamp: new Date().toISOString(),
});
});
/**
* Liveness probe - for container orchestration
*/
app.get('/health/live', (req: Request, res: Response) => {
res.json({
status: 'alive',
uptime: Math.floor((Date.now() - serverStartTime) / 1000),
});
});
/**
* Readiness probe - checks dependencies
*/
app.get('/health/ready', async (req: Request, res: Response) => {
const checks: any = {
express: 'ok',
mcp: 'ok',
tableau: 'not_configured',
};
// Check if Tableau credentials are configured
if (process.env.TABLEAU_SERVER_URL &&
process.env.TABLEAU_SITE_ID &&
process.env.TABLEAU_TOKEN_NAME &&
process.env.TABLEAU_TOKEN_VALUE) {
try {
// Try to create a Tableau client and authenticate
const tableauClient = new TableauClient({
serverUrl: process.env.TABLEAU_SERVER_URL,
siteId: process.env.TABLEAU_SITE_ID,
tokenName: process.env.TABLEAU_TOKEN_NAME,
tokenValue: process.env.TABLEAU_TOKEN_VALUE,
apiVersion: process.env.TABLEAU_API_VERSION || '3.21',
});
await tableauClient.authenticate();
checks.tableau = 'ok';
} catch (error: any) {
checks.tableau = 'error';
Logger.error('Tableau health check failed', error);
}
}
const isHealthy = Object.values(checks).every(v => v === 'ok' || v === 'not_configured');
res.status(isHealthy ? 200 : 503).json({
status: isHealthy ? 'ready' : 'not_ready',
service: 'tableau-mcp-server',
version: '1.0.0',
timestamp: new Date().toISOString(),
uptime: Math.floor((Date.now() - serverStartTime) / 1000),
checks,
});
});
// ============================================================================
// SSE ENDPOINT WITH MCP TRANSPORT
// ============================================================================
/**
* SSE endpoint for MCP protocol communication
* Protected by API key authentication
*/
app.get('/sse', authenticateAPIKey, async (req: Request, res: Response) => {
Logger.info('SSE connection initiated', {
requestId: (req as any).requestId,
});
try {
// Create SSE transport
const transport = new SSEServerTransport('/message', res);
// Connect MCP server to transport
await mcpServer.connect(transport);
Logger.info('SSE connection established', {
requestId: (req as any).requestId,
});
// Handle connection close
res.on('close', () => {
Logger.info('SSE connection closed', {
requestId: (req as any).requestId,
});
});
} catch (error: any) {
Logger.error('SSE connection failed', error, {
requestId: (req as any).requestId,
});
if (!res.headersSent) {
res.status(500).json({
error: {
message: 'Failed to establish SSE connection',
code: 'SSE_CONNECTION_FAILED',
statusCode: 500,
timestamp: new Date().toISOString(),
},
});
}
}
});
/**
* POST endpoint for MCP messages (required by SSE transport)
* Protected by API key authentication
*/
app.post('/message', authenticateAPIKey, async (req: Request, res: Response) => {
Logger.info('MCP message received', {
requestId: (req as any).requestId,
});
// Message handling is done by the SSE transport
res.status(204).send();
});
// ============================================================================
// ERROR HANDLING
// ============================================================================
/**
* 404 handler for unknown routes
*/
app.use((req: Request, res: Response) => {
Logger.warn('Route not found', {
requestId: (req as any).requestId,
method: req.method,
path: req.path,
});
res.status(404).json({
error: {
message: `Route not found: ${req.method} ${req.path}`,
code: 'NOT_FOUND',
statusCode: 404,
timestamp: new Date().toISOString(),
},
});
});
/**
* Global error handler
*/
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
Logger.error('Unhandled error', err, {
requestId: (req as any).requestId,
method: req.method,
path: req.path,
});
const statusCode = err.statusCode || 500;
const errorCode = err.code || 'INTERNAL_ERROR';
res.status(statusCode).json({
error: {
message: err.message || 'Internal server error',
code: errorCode,
statusCode,
timestamp: new Date().toISOString(),
},
});
});
// ============================================================================
// SERVER STARTUP
// ============================================================================
if (NODE_ENV !== 'test') {
app.listen(PORT, () => {
Logger.info('Tableau MCP Server started', {
port: PORT,
environment: NODE_ENV,
nodeVersion: process.version,
endpoints: {
sse: '/sse',
health: '/health',
healthLive: '/health/live',
healthReady: '/health/ready',
},
});
console.log('\n=================================================');
console.log('š Tableau MCP Server - Phase 5 Complete');
console.log('=================================================');
console.log(`š” Server running on port ${PORT}`);
console.log(`š SSE endpoint: http://localhost:${PORT}/sse`);
console.log(`š Health check: http://localhost:${PORT}/health`);
console.log(`š Authentication: X-API-Key header required for /sse`);
console.log(`š Tools: ${toolRegistry.length} tools registered (6 core + 3 advanced)`);
console.log('=================================================');
console.log('Available Tools:');
toolRegistry.forEach(tool => {
console.log(` - ${tool.name}`);
});
console.log('=================================================\n');
});
}
// ============================================================================
// GRACEFUL SHUTDOWN
// ============================================================================
process.on('SIGTERM', () => {
Logger.info('SIGTERM received, shutting down gracefully');
process.exit(0);
});
process.on('SIGINT', () => {
Logger.info('SIGINT received, shutting down gracefully');
process.exit(0);
});
// ============================================================================
// EXPORTS
// ============================================================================
export default app;
export { mcpServer, Logger, toolRegistry };