Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
7 changes: 4 additions & 3 deletions src/tools/listDatasources/listDatasources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,16 +86,17 @@ 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,
requestId,
requestId.toString(),
);
return new Ok(
await restApi.datasourcesMethods.listDatasources(restApi.siteId, validatedFilter ?? ''),
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
7 changes: 4 additions & 3 deletions src/tools/listFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,18 @@ 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,
requestId,
requestId.toString(),
);
return new Ok(await restApi.metadataMethods.graphql(query));
},
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
9 changes: 5 additions & 4 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 @@ -51,12 +52,12 @@ export const queryDatasourceTool = new Tool({
const restApi = await getNewRestApiInstanceAsync(
config.server,
config.authConfig,
requestId,
requestId.toString(),
);

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
7 changes: 4 additions & 3 deletions src/tools/readMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,17 @@ 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,
requestId,
requestId.toString(),
);
return new Ok(
await restApi.vizqlDataServiceMethods.readMetadata({
Expand Down
33 changes: 19 additions & 14 deletions src/tools/tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,6 @@ import { Tool } from './tool.js';
// Mock server.server.sendLoggingMessage since the transport won't be connected.
vi.spyOn(server.server, 'sendLoggingMessage').mockImplementation(vi.fn());

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

describe('Tool', () => {
const mockParams = {
name: 'list-fields',
Expand Down Expand Up @@ -44,10 +40,11 @@ describe('Tool', () => {
const tool = new Tool(mockParams);
const testArgs = { param1: 'test' };

tool.logInvocation(testArgs);
tool.logInvocation({ requestId: '2', args: testArgs });

expect(spy).toHaveBeenCalledExactlyOnceWith({
type: 'tool',
requestId: '2',
tool: {
name: 'list-fields',
args: testArgs,
Expand All @@ -64,6 +61,7 @@ describe('Tool', () => {

const spy = vi.spyOn(tool, 'logInvocation');
const result = await tool.logAndExecute({
requestId: '2',
args: { param1: 'test' },
callback,
});
Expand All @@ -72,7 +70,12 @@ describe('Tool', () => {
expect(result.content[0].type).toBe('text');
expect(JSON.parse(result.content[0].text as string)).toEqual(successResult);

expect(spy).toHaveBeenCalledExactlyOnceWith({ param1: 'test' });
expect(spy).toHaveBeenCalledExactlyOnceWith({
requestId: '2',
args: {
param1: 'test',
},
});
});

it('should return error result when callback throws', async () => {
Expand All @@ -83,22 +86,25 @@ describe('Tool', () => {
});

const result = await tool.logAndExecute({
requestId: '2',
args: { param1: 'test' },
callback,
});

expect(result.isError).toBe(true);
expect(result.content[0].type).toBe('text');
expect(result.content[0].text).toBe(
'requestId: 123e4567-e89b-12d3-a456-426614174000, error: Test error',
);
expect(result.content[0].text).toBe('requestId: 2, error: Test error');
});

it('should call argsValidator with provided args', async () => {
const tool = new Tool(mockParams);
const args = { param1: 'test' };

await tool.logAndExecute({ args, callback: vi.fn() });
await tool.logAndExecute({
requestId: '2',
args,
callback: vi.fn(),
});

expect(mockParams.argsValidator).toHaveBeenCalledWith(args);
});
Expand All @@ -121,14 +127,13 @@ describe('Tool', () => {
});

const result = await tool.logAndExecute({
requestId: '2',
args: { param1: 'test' },
callback: (param1) => Promise.resolve(Ok(param1)),
callback: () => Promise.resolve(Ok('test')),
});

expect(result.isError).toBe(true);
expect(result.content[0].type).toBe('text');
expect(result.content[0].text).toBe(
'requestId: 123e4567-e89b-12d3-a456-426614174000, error: Test error',
);
expect(result.content[0].text).toBe('requestId: 2, error: Test error');
});
});
Loading