Skip to main content
Glama
server.ts•20 kB
/** * 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 };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/russelenriquez-agile/tableau-mcp-project'

If you have feedback or need assistance with the MCP directory API, please join our Discord server