diff --git a/.vscode/launch.json b/.vscode/launch.json index 8a72e9528..344b071d0 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,14 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "type": "node", + "request": "attach", + "name": "Attach to Node", + "port": 9229, + "restart": true, + "skipFiles": ["/**"] + }, { "type": "node", "request": "launch", diff --git a/README.combined.md b/README.combined.md new file mode 100644 index 000000000..626d61a36 --- /dev/null +++ b/README.combined.md @@ -0,0 +1 @@ +# Tableau MCP diff --git a/package.json b/package.json index da8bf084c..14bdae371 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "scripts": { "build": "tsx src/scripts/build.ts", "build:desktop": "tsx src/scripts/build.ts --variant desktop", + "build:combined": "tsx src/scripts/build.ts --variant combined", "build:dev": "tsx src/scripts/build.ts --dev", "build:docker": "docker build -t tableau-mcp .", ":build:mcpb": "npx -y @anthropic-ai/mcpb pack . tableau-mcp.mcpb", diff --git a/src/index.combined.ts b/src/index.combined.ts new file mode 100644 index 000000000..4b5bb7723 --- /dev/null +++ b/src/index.combined.ts @@ -0,0 +1,86 @@ +#!/usr/bin/env node +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import dotenv from 'dotenv'; + +import pkg from '../package.json'; +import { getConfig } from './config.js'; +import { getTableauServerInfo } from './getTableauServerInfo.js'; +import { FileLogger, setFileLogger } from './logging/fileLogger.js'; +import { writeToStderr } from './logging/logger.js'; +import { isNotificationLevel, notifier, setNotificationLevel } from './logging/notification.js'; +import { RestApi } from './sdks/tableau/restApi.js'; +import { DesktopMcpServer } from './server.desktop.js'; +import { WebMcpServer } from './server.web.js'; +import { getExceptionMessage } from './utils/getExceptionMessage.js'; + +const serverName = 'tableau-combined-mcp'; +const serverVersion = pkg.version; + +async function startServer(): Promise { + dotenv.config(); + const config = getConfig(); + + if (config.transport !== 'stdio') { + throw new Error('Transport must be stdio for Desktop server'); + } + + RestApi.host = config.server; + + // Start fetching server info immediately but don't block the port from opening. + // Any failure here is fatal and logged explicitly -- no silent failures. + // For http transport, the port opens first so health checks can succeed, + // then we await this before declaring the server ready. + // For stdio transport, there are no health checks, but we still await before serving. + const serverInfoReady = getTableauServerInfo(config.server).catch((error) => { + writeToStderr(`Fatal error initializing server info: ${getExceptionMessage(error)}`); + process.exit(1); + }); + + const logLevel = isNotificationLevel(config.defaultLogLevel) ? config.defaultLogLevel : 'debug'; + if (config.loggers.has('fileLogger')) { + setFileLogger(new FileLogger({ logDirectory: config.fileLoggerDirectory })); + } + + await serverInfoReady; + + const mcpServer = new McpServer( + { + name: serverName, + version: serverVersion, + }, + { + capabilities: { + logging: {}, + tools: {}, + }, + }, + ); + + const webMcpServer = new WebMcpServer({ mcpServer }); + await webMcpServer.registerTools(); + + const desktopMcpServer = new DesktopMcpServer({ mcpServer }); + await desktopMcpServer.registerTools(); + + mcpServer.server.setRequestHandler(SetLevelRequestSchema, async (request) => { + setNotificationLevel(desktopMcpServer.mcpServer, request.params.level); + return {}; + }); + + const transport = new StdioServerTransport(); + await mcpServer.connect(transport); + + setNotificationLevel(mcpServer, logLevel); + notifier.info(mcpServer, `${serverName} v${serverVersion} running on stdio`); + + if (config.disableLogMasking) { + writeToStderr('⚠️ Log masking is disabled!'); + } +} + +startServer().catch((error) => { + writeToStderr(`Fatal error when starting the server: ${getExceptionMessage(error)}`); + process.exit(1); +}); diff --git a/src/index.desktop.ts b/src/index.desktop.ts index d6f33f05c..dcabe1aed 100644 --- a/src/index.desktop.ts +++ b/src/index.desktop.ts @@ -1,4 +1,5 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import dotenv from 'dotenv'; import { getDesktopConfig } from './config.desktop.js'; @@ -23,13 +24,16 @@ async function startServer(): Promise { const server = new DesktopMcpServer(); await server.registerTools(); - server.registerRequestHandlers(); + server.mcpServer.server.setRequestHandler(SetLevelRequestSchema, async (request) => { + setNotificationLevel(server.mcpServer, request.params.level); + return {}; + }); const transport = new StdioServerTransport(); await server.mcpServer.connect(transport); - setNotificationLevel(server, logLevel); - notifier.info(server, `${server.name} v${server.version} running on stdio`); + setNotificationLevel(server.mcpServer, logLevel); + notifier.info(server.mcpServer, `${server.name} v${server.version} running on stdio`); } startServer().catch((error) => { diff --git a/src/index.ts b/src/index.ts index 281ad6b0c..2b29c81b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import dotenv from 'dotenv'; import pkg from '../package.json'; @@ -42,13 +43,16 @@ async function startServer(): Promise { const server = new WebMcpServer(); await server.registerTools(); - server.registerRequestHandlers(); + server.mcpServer.server.setRequestHandler(SetLevelRequestSchema, async (request) => { + setNotificationLevel(server.mcpServer, request.params.level); + return {}; + }); const transport = new StdioServerTransport(); await server.mcpServer.connect(transport); - setNotificationLevel(server, logLevel); - notifier.info(server, `${server.name} v${server.version} running on stdio`); + setNotificationLevel(server.mcpServer, logLevel); + notifier.info(server.mcpServer, `${server.name} v${server.version} running on stdio`); break; } case 'http': { diff --git a/src/logging/notification.test.ts b/src/logging/notification.test.ts index 9f9c15163..c4a9152cb 100644 --- a/src/logging/notification.test.ts +++ b/src/logging/notification.test.ts @@ -35,22 +35,22 @@ describe('notification', () => { describe('setLogLevel', () => { it('should set the log level', () => { - setNotificationLevel(new WebMcpServer(), 'error', { silent: true }); + setNotificationLevel(new WebMcpServer().mcpServer, 'error', { silent: true }); expect(shouldNotifyWhenLevelIsAtLeast('error')).toBe(true); expect(shouldNotifyWhenLevelIsAtLeast('debug')).toBe(false); }); it('should not change level if it is the same', () => { const server = new WebMcpServer(); - setNotificationLevel(server, 'debug', { silent: true }); - setNotificationLevel(server, 'debug', { silent: true }); + setNotificationLevel(server.mcpServer, 'debug', { silent: true }); + setNotificationLevel(server.mcpServer, 'debug', { silent: true }); expect(server.mcpServer.server.notification).not.toHaveBeenCalled(); }); }); describe('shouldLogWhenLevelIsAtLeast', () => { it('should return true for levels at or above current level', () => { - setNotificationLevel(new WebMcpServer(), 'warning', { silent: true }); + setNotificationLevel(new WebMcpServer().mcpServer, 'warning', { silent: true }); expect(shouldNotifyWhenLevelIsAtLeast('warning')).toBe(true); expect(shouldNotifyWhenLevelIsAtLeast('error')).toBe(true); expect(shouldNotifyWhenLevelIsAtLeast('info')).toBe(false); @@ -116,9 +116,9 @@ describe('notification', () => { describe('log functions', () => { it('should send logging message when level is appropriate', async () => { const server = new WebMcpServer(); - setNotificationLevel(server, 'info', { silent: true }); + setNotificationLevel(server.mcpServer, 'info', { silent: true }); - await notifier.info(server, 'test message', { notifier: 'test-logger' }); + await notifier.info(server.mcpServer, 'test message', { notifier: 'test-logger' }); expect(server.mcpServer.server.notification).toHaveBeenCalledWith( { @@ -137,18 +137,18 @@ describe('notification', () => { it('should not send logging message when level is below current level', async () => { const server = new WebMcpServer(); - setNotificationLevel(server, 'warning', { silent: true }); + setNotificationLevel(server.mcpServer, 'warning', { silent: true }); - await notifier.debug(server, 'test message', { notifier: 'test-logger' }); + await notifier.debug(server.mcpServer, 'test message', { notifier: 'test-logger' }); expect(server.mcpServer.server.notification).not.toHaveBeenCalled(); }); - it('should use server name as default logger', async () => { + it('should use tableau-mcp as default logger', async () => { const server = new WebMcpServer(); - setNotificationLevel(server, 'info', { silent: true }); + setNotificationLevel(server.mcpServer, 'info', { silent: true }); - await notifier.info(server, 'test message'); + await notifier.info(server.mcpServer, 'test message'); expect(server.mcpServer.server.notification).toHaveBeenCalledWith( { @@ -167,14 +167,14 @@ describe('notification', () => { it('should handle LogMessage objects', async () => { const server = new WebMcpServer(); - setNotificationLevel(server, 'info', { silent: true }); + setNotificationLevel(server.mcpServer, 'info', { silent: true }); const logMessage = { type: 'request', method: 'GET', path: '/test', } as const; - await notifier.info(server, logMessage, { notifier: 'test-logger' }); + await notifier.info(server.mcpServer, logMessage, { notifier: 'test-logger' }); expect(server.mcpServer.server.notification).toHaveBeenCalledWith( { diff --git a/src/logging/notification.ts b/src/logging/notification.ts index dbe800824..7f95d51a4 100644 --- a/src/logging/notification.ts +++ b/src/logging/notification.ts @@ -1,6 +1,6 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { LoggingLevel, RequestId } from '@modelcontextprotocol/sdk/types.js'; -import { Server } from '../server.js'; import { ToolName } from '../tools/toolName.js'; import { getFileLogger } from './fileLogger.js'; @@ -29,7 +29,7 @@ export function isNotificationLevel(level: unknown): level is LoggingLevel { } export const setNotificationLevel = ( - server: Server, + mcpServer: McpServer, level: LoggingLevel, { silent = false }: { silent?: boolean } = {}, ): void => { @@ -40,7 +40,7 @@ export const setNotificationLevel = ( currentNotificationLevel = level; if (!silent) { - notifier.notice(server, `Logging level set to: ${level}`); + notifier.notice(mcpServer, `Logging level set to: ${level}`); } }; @@ -57,7 +57,7 @@ export const notifier = { emergency: getSendNotificationMessageFn('emergency'), } satisfies { [level in LoggingLevel]: ( - server: Server, + mcpServer: McpServer, message: string | NotificationMessage, { notifier, requestId }: NotificationMethodOptions, ) => Promise; @@ -91,11 +91,9 @@ export const getNotificationMessageForTool = ({ function getSendNotificationMessageFn(level: LoggingLevel) { return async ( - server: Server, + mcpServer: McpServer, message: string | NotificationMessage, - { notifier: notifier, requestId }: NotificationMethodOptions = { - notifier: server.name, - }, + { notifier, requestId }: NotificationMethodOptions = { notifier: 'tableau-mcp' }, ) => { getFileLogger()?.log({ message, level, logger: notifier }); @@ -105,7 +103,7 @@ function getSendNotificationMessageFn(level: LoggingLevel) { // server.sendNotification doesn't provide a way to provide the relatedRequestId // so we're using server.notification directly. - return server.mcpServer.server.notification( + return mcpServer.server.notification( { method: 'notifications/message', params: { diff --git a/src/logging/secretMask.test.ts b/src/logging/secretMask.test.ts index 4adccab43..a3383f4fa 100644 --- a/src/logging/secretMask.test.ts +++ b/src/logging/secretMask.test.ts @@ -8,7 +8,7 @@ import { maskRequest, maskResponse } from './secretMask.js'; describe('secretMask', () => { beforeEach(() => { - setNotificationLevel(new WebMcpServer(), 'debug', { silent: true }); + setNotificationLevel(new WebMcpServer().mcpServer, 'debug', { silent: true }); }); it('should mask secrets in requests', () => { @@ -111,7 +111,7 @@ describe('secretMask', () => { }); it('should not include headers and data in the request if the log level is not debug', () => { - setNotificationLevel(new WebMcpServer(), 'info', { silent: true }); + setNotificationLevel(new WebMcpServer().mcpServer, 'info', { silent: true }); const maskedRequest = maskRequest({ method: 'POST', @@ -134,7 +134,7 @@ describe('secretMask', () => { }); it('should not include headers and data in the response if the log level is not debug', () => { - setNotificationLevel(new WebMcpServer(), 'info', { silent: true }); + setNotificationLevel(new WebMcpServer().mcpServer, 'info', { silent: true }); const maskedResponse = maskResponse({ status: 200, @@ -214,7 +214,7 @@ describe('secretMask', () => { }); it('should not include params in the request if the log level is not debug', () => { - setNotificationLevel(new WebMcpServer(), 'info', { silent: true }); + setNotificationLevel(new WebMcpServer().mcpServer, 'info', { silent: true }); const maskedRequest = maskRequest({ method: 'POST', diff --git a/src/restApiInstance.test.ts b/src/restApiInstance.test.ts index 695d3d2d4..6fc81316d 100644 --- a/src/restApiInstance.test.ts +++ b/src/restApiInstance.test.ts @@ -230,7 +230,7 @@ describe('restApiInstance', () => { expect(mockRequest.headers['User-Agent']).toBe(server.userAgent); expect(notifier.info).toHaveBeenCalledWith( - server, + server.mcpServer, expect.objectContaining({ type: 'request', requestId: mockRequestId, @@ -262,7 +262,7 @@ describe('restApiInstance', () => { expect(result).toBe(mockResponse); expect(notifier.info).toHaveBeenCalledWith( - server, + server.mcpServer, expect.objectContaining({ type: 'response', requestId: mockRequestId, @@ -293,7 +293,7 @@ describe('restApiInstance', () => { errorInterceptor(mockError, mockHost); expect(notifier.error).toHaveBeenCalledWith( - server, + server.mcpServer, `Request ${mockRequestId} failed with error: ${JSON.stringify(mockError)}`, expect.objectContaining({ notifier: 'rest-api', @@ -320,7 +320,7 @@ describe('restApiInstance', () => { expect(notifier.info).toHaveBeenCalled(); expect(notifier.info).toHaveBeenCalledWith( - server, + server.mcpServer, expect.objectContaining({ type: 'request', requestId: mockRequestId, @@ -350,7 +350,7 @@ describe('restApiInstance', () => { errorInterceptor(mockError, mockHost); expect(notifier.error).toHaveBeenCalledWith( - server, + server.mcpServer, `Response from request ${mockRequestId} failed with error: ${JSON.stringify(mockError)}`, expect.objectContaining({ notifier: 'rest-api', @@ -377,7 +377,7 @@ describe('restApiInstance', () => { errorInterceptor(mockError, mockHost); expect(notifier.info).toHaveBeenCalledWith( - server, + server.mcpServer, expect.objectContaining({ type: 'response', requestId: mockRequestId, diff --git a/src/restApiInstance.ts b/src/restApiInstance.ts index 83fb35d02..d0b142680 100644 --- a/src/restApiInstance.ts +++ b/src/restApiInstance.ts @@ -68,7 +68,7 @@ const getNewRestApiInstanceAsync = async ( 'abort', () => { notifier.info( - server, + server.mcpServer, { type: 'request-cancelled', requestId, @@ -204,7 +204,7 @@ export const getRequestErrorInterceptor = (error, baseUrl) => { if (!isAxiosError(error) || !error.request) { notifier.error( - server, + server.mcpServer, `Request ${requestId} failed with error: ${getExceptionMessage(error)}`, { notifier: 'rest-api', @@ -237,7 +237,7 @@ export const getResponseErrorInterceptor = (error, baseUrl) => { if (!isAxiosError(error) || !error.response) { notifier.error( - server, + server.mcpServer, `Response from request ${requestId} failed with error: ${getExceptionMessage(error)}`, { notifier: 'rest-api', requestId }, ); @@ -278,7 +278,7 @@ function logRequest(server: Server, request: RequestInterceptorConfig, requestId }), } as const; - notifier.info(server, messageObj, { notifier: 'rest-api', requestId }); + notifier.info(server.mcpServer, messageObj, { notifier: 'rest-api', requestId }); } function logResponse( @@ -305,7 +305,7 @@ function logResponse( }), } as const; - notifier.info(server, messageObj, { notifier: 'rest-api', requestId }); + notifier.info(server.mcpServer, messageObj, { notifier: 'rest-api', requestId }); } function getUserAgent(server: Server): string { diff --git a/src/scripts/prepareVariantPackage.ts b/src/scripts/prepareVariantPackage.ts index 0f3ea7ce1..a93a23ad0 100644 --- a/src/scripts/prepareVariantPackage.ts +++ b/src/scripts/prepareVariantPackage.ts @@ -30,10 +30,19 @@ const variantPackageJsonOverrides = { description: 'MCP server for Tableau Desktop Agent API - enables AI agents to interact with Tableau workbooks', bin: { - 'tableau-desktop-mcp-server': './build/index-desktop.js', + 'tableau-desktop-mcp-server': './build/index.desktop.js', }, exports: { - '.': './build/index-desktop.js', + '.': './build/index.desktop.js', + }, + }, + combined: { + name: '@tableau/combined-mcp-server', + bin: { + 'tableau-combined-mcp-server': './build/index.combined.js', + }, + exports: { + '.': './build/index.combined.js', }, }, } satisfies Record>; diff --git a/src/scripts/variants.ts b/src/scripts/variants.ts index 5c1e0e6bc..e7004ec1f 100644 --- a/src/scripts/variants.ts +++ b/src/scripts/variants.ts @@ -1,4 +1,4 @@ -export const variants = ['default', 'desktop'] as const; +export const variants = ['default', 'desktop', 'combined'] as const; export type Variant = (typeof variants)[number]; export function isVariant(value: unknown): value is Variant { return variants.some((variant) => variant === value); diff --git a/src/server.ts b/src/server.ts index bf28a880e..8e10619f3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,7 +1,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { InitializeRequest, SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { InitializeRequest } from '@modelcontextprotocol/sdk/types.js'; -import { setNotificationLevel } from './logging/notification.js'; import { TableauAuthInfo } from './server/oauth/schemas.js'; export type ClientInfo = InitializeRequest['params']['clientInfo']; @@ -68,11 +67,4 @@ export abstract class Server { } abstract registerTools: (tableauAuthInfo?: TableauAuthInfo) => Promise; - - registerRequestHandlers = (): void => { - this.mcpServer.server.setRequestHandler(SetLevelRequestSchema, async (request) => { - setNotificationLevel(this, request.params.level); - return {}; - }); - }; } diff --git a/src/server.web.test.ts b/src/server.web.test.ts index 32da79120..8a607f322 100644 --- a/src/server.web.test.ts +++ b/src/server.web.test.ts @@ -123,14 +123,6 @@ describe('WebMcpServer', () => { await expect(server.registerTools).rejects.toThrow(sentence); } }); - - it('should register request handlers', async () => { - const server = getServer(); - server.mcpServer.server.setRequestHandler = vi.fn(); - server.registerRequestHandlers(); - - expect(server.mcpServer.server.setRequestHandler).toHaveBeenCalled(); - }); }); function getServer(): WebMcpServer { diff --git a/src/server/express.ts b/src/server/express.ts index 4f29dfcf1..a70a15000 100644 --- a/src/server/express.ts +++ b/src/server/express.ts @@ -1,5 +1,9 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { isInitializeRequest, LoggingLevel } from '@modelcontextprotocol/sdk/types.js'; +import { + isInitializeRequest, + LoggingLevel, + SetLevelRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; import cookieParser from 'cookie-parser'; import cors from 'cors'; import express, { Request, RequestHandler, Response } from 'express'; @@ -182,10 +186,13 @@ async function connect( authInfo: TableauAuthInfo | undefined, ): Promise { await server.registerTools(authInfo); - server.registerRequestHandlers(); + server.mcpServer.server.setRequestHandler(SetLevelRequestSchema, async (request) => { + setNotificationLevel(server.mcpServer, request.params.level); + return {}; + }); await server.mcpServer.connect(transport); - setNotificationLevel(server, logLevel); + setNotificationLevel(server.mcpServer, logLevel); } async function methodNotAllowed(_req: Request, res: Response): Promise { diff --git a/src/tools/tool.ts b/src/tools/tool.ts index 31662ac67..67e065c7d 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -118,7 +118,7 @@ export abstract class Tool< username?: string; }): void { notifier.debug( - this.server, + this.server.mcpServer, getNotificationMessageForTool({ requestId, toolName: this.name, diff --git a/src/tools/web/queryDatasource/validators/validateFilterValues.ts b/src/tools/web/queryDatasource/validators/validateFilterValues.ts index 2c52eae97..a23b044f7 100644 --- a/src/tools/web/queryDatasource/validators/validateFilterValues.ts +++ b/src/tools/web/queryDatasource/validators/validateFilterValues.ts @@ -70,7 +70,7 @@ export async function validateFilterValues( } } catch (error) { notifier.warning( - server, + server.mcpServer, `Filter value validation failed for field ${fieldCaption}: ${error}`, ); }