Skip to content
Merged
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
2,516 changes: 1,139 additions & 1,377 deletions package-lock.json

Large diffs are not rendered by default.

10 changes: 4 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tableau-mcp",
"description": "A MCP server for Tableau, providing a suite of developer primitives, including tools, resources and prompts, that will make it easier for developers to build AI-applications that integrate with Tableau.",
"description": "A 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": "0.1.0",
"homepage": "https://github.com/tableau/tableau-mcp",
"bugs": "https://github.com/tableau/tableau-mcp/issues",
Expand Down Expand Up @@ -29,23 +29,21 @@
"coverage": "vitest run --coverage"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.2",
"@modelcontextprotocol/sdk": "^1.12.1",
"@zodios/core": "^10.9.6",
"jsonwebtoken": "^9.0.2",
"ts-results-es": "^5.0.1",
"zod": "^3.24.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.25.1",
"@modelcontextprotocol/inspector": "^0.12.0",
"@modelcontextprotocol/inspector": "^0.14.0",
"@types/eslint__js": "^8.42.3",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^22.15.3",
"@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1",
"@vitest/coverage-v8": "^3.1.3",
"esbuild": "^0.25.3",
"esbuild": "^0.25.5",
"eslint": "^9.25.1",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.2.6",
Expand Down
17 changes: 13 additions & 4 deletions src/logging/log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,9 @@ describe('log', () => {
});

it('should not change level if it is the same', () => {
const spy = vi.spyOn(server.server, 'sendLoggingMessage');
setLogLevel('debug', { silent: true });
setLogLevel('debug', { silent: true });
expect(spy).not.toHaveBeenCalled();
expect(server.server.sendLoggingMessage).not.toHaveBeenCalled();
});
});

Expand Down Expand Up @@ -95,10 +94,15 @@ describe('log', () => {
describe('getToolLogMessage', () => {
it('should create a tool log message with args', () => {
const args = { param1: 'value1' };
const result = getToolLogMessage('list-fields', args);
const result = getToolLogMessage({
requestId: '2',
toolName: 'list-fields',
args,
});

expect(result).toEqual({
type: 'tool',
requestId: '2',
tool: {
name: 'list-fields',
args,
Expand All @@ -107,10 +111,15 @@ describe('log', () => {
});

it('should create a tool log message without args', () => {
const result = getToolLogMessage('list-fields', undefined);
const result = getToolLogMessage({
requestId: '2',
toolName: 'list-fields',
args: undefined,
});

expect(result).toEqual({
type: 'tool',
requestId: '2',
tool: {
name: 'list-fields',
},
Expand Down
13 changes: 11 additions & 2 deletions src/logging/log.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { LoggingLevel } from '@modelcontextprotocol/sdk/types.js';
import { LoggingLevel, RequestId } from '@modelcontextprotocol/sdk/types.js';

import { server } from '../server.js';
import { ToolName } from '../tools/toolName.js';
Expand Down Expand Up @@ -69,9 +69,18 @@ export const writeToStderr = (message: string): void => {
process.stderr.write(message);
};

export const getToolLogMessage = (toolName: ToolName, args: unknown): LogMessage => {
export const getToolLogMessage = ({
requestId,
toolName,
args,
}: {
requestId: RequestId;
toolName: ToolName;
args: unknown;
}): LogMessage => {
return {
type: 'tool',
requestId,
tool: {
name: toolName,
...(args !== undefined ? { args } : {}),
Expand Down
16 changes: 9 additions & 7 deletions src/restApiInstance.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { RequestId } from '@modelcontextprotocol/sdk/types.js';

import { isAxiosError } from '../node_modules/axios/index.js';
import { getConfig } from './config.js';
import { log, shouldLogWhenLevelIsAtLeast } from './logging/log.js';
Expand All @@ -20,7 +22,7 @@ import { getExceptionMessage } from './utils/getExceptionMessage.js';
export const getNewRestApiInstanceAsync = async (
host: string,
authConfig: AuthConfig,
requestId: string,
requestId: RequestId,
): Promise<RestApi> => {
const restApi = new RestApi(host, {
requestInterceptor: [getRequestInterceptor(requestId), getRequestErrorInterceptor(requestId)],
Expand All @@ -35,15 +37,15 @@ export const getNewRestApiInstanceAsync = async (
};

export const getRequestInterceptor =
(requestId: string): RequestInterceptor =>
(requestId: RequestId): RequestInterceptor =>
(request) => {
request.headers['User-Agent'] = `${server.name}/${server.version}`;
logRequest(request, requestId);
return request;
};

export const getRequestErrorInterceptor =
(requestId: string): ErrorInterceptor =>
(requestId: RequestId): ErrorInterceptor =>
(error, baseUrl) => {
if (!isAxiosError(error) || !error.request) {
log.error(
Expand All @@ -64,14 +66,14 @@ export const getRequestErrorInterceptor =
};

export const getResponseInterceptor =
(requestId: string): ResponseInterceptor =>
(requestId: RequestId): ResponseInterceptor =>
(response) => {
logResponse(response, requestId);
return response;
};

export const getResponseErrorInterceptor =
(requestId: string): ErrorInterceptor =>
(requestId: RequestId): ErrorInterceptor =>
(error, baseUrl) => {
if (!isAxiosError(error) || !error.response) {
log.error(
Expand All @@ -92,7 +94,7 @@ export const getResponseErrorInterceptor =
);
};

function logRequest(request: RequestInterceptorConfig, requestId: string): void {
function logRequest(request: RequestInterceptorConfig, requestId: RequestId): void {
const config = getConfig();
const maskedRequest = config.disableLogMasking ? request : maskRequest(request);
const { baseUrl, url } = maskedRequest;
Expand All @@ -112,7 +114,7 @@ function logRequest(request: RequestInterceptorConfig, requestId: string): void
log.info(messageObj, 'rest-api');
}

function logResponse(response: ResponseInterceptorConfig, requestId: string): void {
function logResponse(response: ResponseInterceptorConfig, requestId: RequestId): void {
const config = getConfig();
const maskedResponse = config.disableLogMasking ? response : maskResponse(response);
const { baseUrl, url } = maskedResponse;
Expand Down
5 changes: 3 additions & 2 deletions src/tools/listDatasources/listDatasources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,13 @@ Retrieves a list of published data sources from a specified Tableau site using t
readOnlyHint: true,
openWorldHint: false,
},
callback: async ({ filter }): Promise<CallToolResult> => {
callback: async ({ filter }, { requestId }): Promise<CallToolResult> => {
const config = getConfig();
const validatedFilter = filter ? parseAndValidateFilterString(filter) : undefined;
return await listDatasourcesTool.logAndExecute({
requestId,
args: { filter },
callback: async (requestId) => {
callback: async () => {
const restApi = await getNewRestApiInstanceAsync(
config.server,
config.authConfig,
Expand Down
8 changes: 1 addition & 7 deletions src/tools/listFields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,6 @@ vi.mock('../restApiInstance.js', () => ({
}),
}));

vi.mock('node:crypto', () => {
return { randomUUID: vi.fn(() => '123e4567-e89b-12d3-a456-426614174000') };
});

describe('listFieldsTool', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -82,9 +78,7 @@ describe('listFieldsTool', () => {

const result = await getToolResult();
expect(result.isError).toBe(true);
expect(result.content[0].text).toBe(
'requestId: 123e4567-e89b-12d3-a456-426614174000, error: API Error',
);
expect(result.content[0].text).toBe('requestId: test-request-id, error: API Error');
});
});

Expand Down
5 changes: 3 additions & 2 deletions src/tools/listFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,14 @@ export const listFieldsTool = new Tool({
openWorldHint: false,
},
argsValidator: validateDatasourceLuid,
callback: async ({ datasourceLuid }): Promise<CallToolResult> => {
callback: async ({ datasourceLuid }, { requestId }): Promise<CallToolResult> => {
const config = getConfig();
const query = getGraphqlQuery(datasourceLuid);

return await listFieldsTool.logAndExecute({
requestId,
args: { datasourceLuid },
callback: async (requestId) => {
callback: async () => {
const restApi = await getNewRestApiInstanceAsync(
config.server,
config.authConfig,
Expand Down
10 changes: 2 additions & 8 deletions src/tools/queryDatasource/queryDatasource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,6 @@ vi.mock('../../restApiInstance.js', () => ({
}),
}));

vi.mock('node:crypto', () => {
return { randomUUID: vi.fn(() => '123e4567-e89b-12d3-a456-426614174000') };
});

describe('queryDatasourceTool', () => {
const originalEnv = process.env;

Expand Down Expand Up @@ -156,7 +152,7 @@ describe('queryDatasourceTool', () => {
expect(result.isError).toBe(true);
expect(result.content[0].text).toBe(
JSON.stringify({
requestId: '123e4567-e89b-12d3-a456-426614174000',
requestId: 'test-request-id',
...mockVdsResponses.error,
condition: 'Validation failed',
details: "The incoming request isn't valid per the validation rules.",
Expand Down Expand Up @@ -192,9 +188,7 @@ describe('queryDatasourceTool', () => {

const result = await getToolResult();
expect(result.isError).toBe(true);
expect(result.content[0].text).toBe(
'requestId: 123e4567-e89b-12d3-a456-426614174000, error: API Error',
);
expect(result.content[0].text).toBe('requestId: test-request-id, error: API Error');
});
});

Expand Down
7 changes: 4 additions & 3 deletions src/tools/queryDatasource/queryDatasource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ export const queryDatasourceTool = new Tool({
openWorldHint: false,
},
argsValidator: validateQuery,
callback: async ({ datasourceLuid, query }): Promise<CallToolResult> => {
callback: async ({ datasourceLuid, query }, { requestId }): Promise<CallToolResult> => {
const config = getConfig();
return await queryDatasourceTool.logAndExecute({
requestId,
args: { datasourceLuid, query },
callback: async (requestId) => {
callback: async () => {
const datasource: Datasource = { datasourceLuid };
const options = {
returnFormat: 'OBJECTS',
Expand All @@ -56,7 +57,7 @@ export const queryDatasourceTool = new Tool({

return await restApi.vizqlDataServiceMethods.queryDatasource(queryRequest);
},
getErrorText: (requestId: string, error: z.infer<typeof TableauError>) => {
getErrorText: (error: z.infer<typeof TableauError>) => {
return JSON.stringify({ requestId, ...handleQueryDatasourceError(error) });
},
});
Expand Down
8 changes: 1 addition & 7 deletions src/tools/readMetadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,6 @@ vi.mock('../restApiInstance.js', () => ({
}),
}));

vi.mock('node:crypto', () => {
return { randomUUID: vi.fn(() => '123e4567-e89b-12d3-a456-426614174000') };
});

describe('readMetadataTool', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -95,9 +91,7 @@ describe('readMetadataTool', () => {

const result = await getToolResult();
expect(result.isError).toBe(true);
expect(result.content[0].text).toBe(
'requestId: 123e4567-e89b-12d3-a456-426614174000, error: API Error',
);
expect(result.content[0].text).toBe('requestId: test-request-id, error: API Error');
});
});

Expand Down
5 changes: 3 additions & 2 deletions src/tools/readMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@ export const readMetadataTool = new Tool({
openWorldHint: false,
},
argsValidator: validateDatasourceLuid,
callback: async ({ datasourceLuid }): Promise<CallToolResult> => {
callback: async ({ datasourceLuid }, { requestId }): Promise<CallToolResult> => {
const config = getConfig();

return await readMetadataTool.logAndExecute({
requestId,
args: { datasourceLuid },
callback: async (requestId) => {
callback: async () => {
const restApi = await getNewRestApiInstanceAsync(
config.server,
config.authConfig,
Expand Down
Loading