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
19 changes: 17 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@tableau/mcp-server",
"description": "An MCP server for Tableau, providing a suite of tools that will make it easier for developers to build AI applications that integrate with Tableau.",
"version": "1.13.8",
"version": "1.13.9",
"repository": {
"type": "git",
"url": "git+https://github.com/tableau/tableau-mcp.git"
Expand All @@ -22,15 +22,17 @@
"clean": "npx rimraf ./build",
":build": "esbuild src/index.ts --bundle --platform=node --format=cjs --minify --outfile=build/index.js --sourcemap --log-override:empty-import-meta=silent",
":build:dev": "esbuild src/index.ts --bundle --packages=external --platform=node --format=cjs --outfile=build/index.js --sourcemap",
"build": "run-s clean :build exec-perms",
"build:dev": "run-s clean :build:dev exec-perms",
"build": "run-s clean :build :build:tracing exec-perms",
"build:dev": "run-s clean :build:dev :build:tracing exec-perms",
"build:watch": "npm run :build:dev -- --watch",
"build:docker": "docker build -t tableau-mcp .",
":build:mcpb": "npx -y @anthropic-ai/mcpb pack . tableau-mcp.mcpb",
"build:mcpb": "run-s build:manifest:script build:manifest :build:mcpb",
"build:manifest": "node build/scripts/createClaudeMcpBundleManifest.mjs",
"build:manifest:script": "esbuild src/scripts/createClaudeMcpBundleManifest.ts --bundle --platform=node --format=esm --outdir=build/scripts --sourcemap=inline --out-extension:.js=.mjs",
"start:http": "node build/index.js",
"start:http:apm": "node -r ./build/telemetry/tracing.js build/index.js",
":build:tracing": "esbuild src/telemetry/tracing.ts --bundle --packages=external --platform=node --format=cjs --outfile=build/telemetry/tracing.js",
"start:http:docker": "docker run -p 3927:3927 -i --rm --env-file env.list tableau-mcp",
"lint": "npm exec eslint",
"inspect": "npx @modelcontextprotocol/inspector --config config.json --server tableau",
Expand Down
11 changes: 11 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { CorsOptions } from 'cors';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';

import { TelemetryConfig } from './telemetry/types.js';
import { isToolGroupName, isToolName, toolGroups, ToolName } from './tools/toolName.js';
import { isTransport, TransportName } from './transports.js';
import { getDirname } from './utils/getDirname.js';
Expand Down Expand Up @@ -76,6 +77,7 @@ export class Config {
clientIdSecretPairs: Record<string, string> | null;
dnsServers: string[];
};
telemetry: TelemetryConfig;

constructor() {
const cleansedVars = removeClaudeMcpBundleUserConfigTemplates(process.env);
Expand Down Expand Up @@ -129,6 +131,9 @@ export class Config {
OAUTH_AUTHORIZATION_CODE_TIMEOUT_MS: authzCodeTimeoutMs,
OAUTH_ACCESS_TOKEN_TIMEOUT_MS: accessTokenTimeoutMs,
OAUTH_REFRESH_TOKEN_TIMEOUT_MS: refreshTokenTimeoutMs,
TELEMETRY_ENABLED: telemetryEnabled,
TELEMETRY_PROVIDER: telemetryProvider,
TELEMETRY_PROVIDER_CONFIG: telemetryProviderConfig,
} = cleansedVars;

let jwtUsername = '';
Expand Down Expand Up @@ -223,6 +228,12 @@ export class Config {
: null,
};

this.telemetry = {
enabled: telemetryEnabled === 'true',
provider: (telemetryProvider as 'noop' | 'moncloud' | 'custom') || 'noop',
Copy link
Collaborator

@anyoung-tableau anyoung-tableau Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using a type guard for the telemtryProvider, similar to how this.auth is set below. Currently the runtime value could be anything.

Something like:

const telemetryProviders = ["noop", "moncloud", "custom"] as const;
type TelemetryProvider = (typeof telemetryProviders)[number];

export function isTelemetryProvider(value: unknown): value is TelemetryProvider {
    return !!(telemetryProviders.find((t) => t === value));
}

...
provider: isTelemetryProvider(telemetryProvider) ? telemetryProvider : 'noop',

providerConfig: telemetryProviderConfig ? JSON.parse(telemetryProviderConfig) : undefined,
};

this.auth = isAuthType(auth) ? auth : this.oauth.enabled ? 'oauth' : 'pat';
this.transport = isTransport(transport) ? transport : this.oauth.enabled ? 'http' : 'stdio';

Expand Down
113 changes: 113 additions & 0 deletions src/telemetry/init.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';

// Mock the config module
vi.mock('../config.js', () => ({
getConfig: vi.fn(),
}));

// Mock the provider modules
vi.mock('./moncloud.js', () => ({
MonCloudTelemetryProvider: vi.fn().mockImplementation(() => ({
initialize: vi.fn(),
addAttributes: vi.fn(),
})),
}));

vi.mock('./noop.js', () => ({
NoOpTelemetryProvider: vi.fn().mockImplementation(() => ({
initialize: vi.fn(),
addAttributes: vi.fn(),
})),
}));

import { getConfig } from '../config.js';
import { initializeTelemetry } from './init.js';
import { MonCloudTelemetryProvider } from './moncloud.js';
import { NoOpTelemetryProvider } from './noop.js';
import { TelemetryConfig } from './types.js';

describe('initializeTelemetry', () => {
const mockGetConfig = getConfig as Mock;

const defaultTelemetryConfig: TelemetryConfig = {
enabled: true,
provider: 'noop',
};

beforeEach(() => {
vi.clearAllMocks();
});

// MonCloud tests
it('returns MonCloudTelemetryProvider when provider is "moncloud"', () => {
mockGetConfig.mockReturnValue({
telemetry: { ...defaultTelemetryConfig, provider: 'moncloud' },
});

initializeTelemetry();

expect(MonCloudTelemetryProvider).toHaveBeenCalled();
});

// NoOp tests
it('returns NoOpTelemetryProvider when telemetry is disabled', () => {
mockGetConfig.mockReturnValue({
telemetry: { ...defaultTelemetryConfig, enabled: false },
});

const provider = initializeTelemetry();

expect(NoOpTelemetryProvider).toHaveBeenCalled();
expect(provider.initialize).toBeDefined();
expect(provider.addAttributes).toBeDefined();
});

it('returns NoOpTelemetryProvider when provider is "noop"', () => {
mockGetConfig.mockReturnValue({
telemetry: { ...defaultTelemetryConfig, provider: 'noop' },
});

initializeTelemetry();

expect(NoOpTelemetryProvider).toHaveBeenCalled();
});

it('returns NoOpTelemetryProvider for unknown provider with warning', () => {
mockGetConfig.mockReturnValue({
telemetry: { ...defaultTelemetryConfig, provider: 'unknown-provider' },
});

initializeTelemetry();

expect(NoOpTelemetryProvider).toHaveBeenCalled();
});

it('falls back to NoOpTelemetryProvider on initialization error', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

// Make MonCloudTelemetryProvider throw during initialization
(MonCloudTelemetryProvider as Mock).mockImplementationOnce(() => ({
initialize: vi.fn().mockImplementation(() => {
throw new Error('Init failed');
}),
addAttributes: vi.fn(),
}));

mockGetConfig.mockReturnValue({
telemetry: { ...defaultTelemetryConfig, provider: 'moncloud' },
});

const provider = initializeTelemetry();

expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to initialize telemetry provider:',
expect.any(Error),
);
expect(consoleWarnSpy).toHaveBeenCalledWith('Falling back to NoOp telemetry provider');
expect(provider).toBeDefined();

consoleErrorSpy.mockRestore();
consoleWarnSpy.mockRestore();
});
});
156 changes: 156 additions & 0 deletions src/telemetry/init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* Telemetry initialization and provider factory
*/

import { resolve } from 'path';

import { getConfig } from '../config.js';
import { MonCloudTelemetryProvider } from './moncloud.js';
import { NoOpTelemetryProvider } from './noop.js';
import { TelemetryProvider } from './types.js';

/**
* Initialize the telemetry provider based on configuration.
*
* This function should be called early in application startup, before any
* HTTP requests or other instrumented operations occur.
*
* @returns A configured telemetry provider
*
* @example
* ```typescript
* function main() {
* // Initialize telemetry first
* const telemetry = initializeTelemetry();
*
* // Add global attributes
* telemetry.addAttributes({
* 'tableau.server': config.server,
* 'mcp.version': '1.0.0',
* });
*
* // Start application...
* }
* ```
*/
export function initializeTelemetry(): TelemetryProvider {
const config = getConfig();

// If telemetry is disabled, use NoOp provider
if (!config.telemetry.enabled) {
const provider = new NoOpTelemetryProvider();
provider.initialize();
return provider;
}

let provider: TelemetryProvider;

try {
// Select provider based on configuration
switch (config.telemetry.provider) {
case 'moncloud':
Copy link
Contributor Author

@jarhun88 jarhun88 Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think having separate cases for moncloud vs custom will become even more apparent when i need to write custom metrics for the tools using telemetry.addAttributes(), as each underlying telemetry provider has different apis for doing that

provider = new MonCloudTelemetryProvider();
break;

case 'custom':
// Load custom provider from user's filesystem
provider = loadCustomProvider(config.telemetry.providerConfig);
break;

case 'noop':
default:
if (config.telemetry.provider !== 'noop') {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type of config.telemetry.provider and the return type on this method prevent this default case from ever happening. If you remove the default case and, say, add a new provider type without adding the new case for the new provider, TypeScript will yell at you because the method could potentially now return undefined

console.warn(
`Unknown telemetry provider: ${config.telemetry.provider}. Using NoOp provider.`,
);
}
provider = new NoOpTelemetryProvider();
}

// Initialize the provider
provider.initialize();
return provider;
} catch (error) {
console.error('Failed to initialize telemetry provider:', error);
console.warn('Falling back to NoOp telemetry provider');

// Fallback to NoOp on error - telemetry failures shouldn't break the application
const fallbackProvider = new NoOpTelemetryProvider();
fallbackProvider.initialize();
return fallbackProvider;
}
}

/**
* Load a custom telemetry provider from user's filesystem or npm package.
*
* The custom provider module should export a default class that implements TelemetryProvider.
*
* @param config - Provider configuration containing the module path
* @returns A configured custom telemetry provider
*
* @example Custom provider from file
* ```bash
* TELEMETRY_PROVIDER=custom
* TELEMETRY_PROVIDER_CONFIG='{"module":"./my-telemetry.js"}'
* ```
*
* @example Custom provider from npm package
* ```bash
* TELEMETRY_PROVIDER=custom
* TELEMETRY_PROVIDER_CONFIG='{"module":"my-company-telemetry"}'
* ```
*/
function loadCustomProvider(config?: Record<string, unknown>): TelemetryProvider {
if (!config?.module) {
throw new Error(
'Custom telemetry provider requires "module" in providerConfig. ' +
'Example: TELEMETRY_PROVIDER_CONFIG=\'{"module":"./my-telemetry.js"}\'',
);
}

const modulePath = config.module as string;

// Determine if it's a file path or npm package name
let resolvedPath: string;

if (modulePath.startsWith('.') || modulePath.startsWith('/')) {
// File path - resolve relative to process working directory (user's project root)
resolvedPath = resolve(process.cwd(), modulePath);
} else {
// npm package name - require as-is
resolvedPath = modulePath;
}

try {
// eslint-disable-next-line @typescript-eslint/no-require-imports -- Sync load for preload script
const module = require(resolvedPath);

// Look for default export or named export "TelemetryProvider"
const ProviderClass = module.default || module.TelemetryProvider;

if (!ProviderClass) {
throw new Error(
`Module ${modulePath} must export a default class or named export "TelemetryProvider" ` +
'that implements the TelemetryProvider interface',
);
}

// Instantiate the provider with the full config
return new ProviderClass(config);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before returning the instance, let's verify the class implements the interface. I did something similar here: https://github.com/tableau/tableau-mcp/blob/anyoung/session-store/src/server/storage/storeFactory.ts#L157

} catch (error) {
// Provide helpful error message with common issues
let errorMessage = `Failed to load custom telemetry provider from "${modulePath}". `;

if ((error as any).code === 'MODULE_NOT_FOUND') {
errorMessage +=
'Module not found. ' +
'If using a file path, ensure the file exists and the path is correct. ' +
'If using an npm package, ensure it is installed.';
} else {
errorMessage += `Error: ${error}`;
}

throw new Error(errorMessage);
}
}
Loading