Skip to content
Open
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
8 changes: 8 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": ["<node_internals>/**"]
},
{
"type": "node",
"request": "launch",
Expand Down
1 change: 1 addition & 0 deletions README.combined.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Tableau MCP
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
86 changes: 86 additions & 0 deletions src/index.combined.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 });
Comment thread
anyoung-tableau marked this conversation as resolved.
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);
});
10 changes: 7 additions & 3 deletions src/index.desktop.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -23,13 +24,16 @@ async function startServer(): Promise<void> {

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) => {
Expand Down
10 changes: 7 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -42,13 +43,16 @@ async function startServer(): Promise<void> {

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': {
Expand Down
26 changes: 13 additions & 13 deletions src/logging/notification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(
{
Expand All @@ -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(
{
Expand All @@ -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(
{
Expand Down
16 changes: 7 additions & 9 deletions src/logging/notification.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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 => {
Expand All @@ -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}`);
}
};

Expand All @@ -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<void>;
Expand Down Expand Up @@ -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 });

Expand All @@ -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: {
Expand Down
8 changes: 4 additions & 4 deletions src/logging/secretMask.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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',
Expand All @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
12 changes: 6 additions & 6 deletions src/restApiInstance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -262,7 +262,7 @@ describe('restApiInstance', () => {

expect(result).toBe(mockResponse);
expect(notifier.info).toHaveBeenCalledWith(
server,
server.mcpServer,
expect.objectContaining({
type: 'response',
requestId: mockRequestId,
Expand Down Expand Up @@ -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',
Expand All @@ -320,7 +320,7 @@ describe('restApiInstance', () => {
expect(notifier.info).toHaveBeenCalled();

expect(notifier.info).toHaveBeenCalledWith(
server,
server.mcpServer,
expect.objectContaining({
type: 'request',
requestId: mockRequestId,
Expand Down Expand Up @@ -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',
Expand All @@ -377,7 +377,7 @@ describe('restApiInstance', () => {
errorInterceptor(mockError, mockHost);

expect(notifier.info).toHaveBeenCalledWith(
server,
server.mcpServer,
expect.objectContaining({
type: 'response',
requestId: mockRequestId,
Expand Down
Loading