diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e6a6e858..0ece4186 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -32,6 +32,13 @@ } }, "detail": "Run build:watch in background" + }, + { + "label": "npm test", + "type": "npm", + "script": "test", + "group": "test", + "detail": "Run test" } ] } diff --git a/README.md b/README.md index bed088d1..89fcae04 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ The following MCP tools are currently implemented: | list-fields | Fetches field metadata (name, description) for the specified datasource ([Metadata API][meta]) | | query-datasource | Run a Tableau VizQL query ([VDS API][vds]) | | read-metadata | Requests metadata for the specified data source ([VDS API][vds]) | +| list-flows | Retrieves a list of published Prep flows from a specified Tableau site ([REST API][query]) | Note: The Tableau MCP project is currently in early development. As we continue to enhance and refine the implementation, the available functionality and tools may evolve. We welcome feedback and diff --git a/package-lock.json b/package-lock.json index b272ca08..55c20a6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3345,6 +3345,7 @@ "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, diff --git a/src/sdks/tableau/apis/flowsApi.ts b/src/sdks/tableau/apis/flowsApi.ts new file mode 100644 index 00000000..82302647 --- /dev/null +++ b/src/sdks/tableau/apis/flowsApi.ts @@ -0,0 +1,54 @@ +import { makeApi, makeEndpoint, ZodiosEndpointDefinitions } from '@zodios/core'; +import { z } from 'zod'; + +import { flowSchema } from '../types/flow.js'; +import { paginationSchema } from '../types/pagination.js'; + +const listFlowsRestEndpoint = makeEndpoint({ + method: 'get', + path: '/sites/:siteId/flows', + alias: 'listFlows', + description: + 'Returns a list of flows on the specified site. Supports filter, sort, page-size, and page-number as query parameters.', + parameters: [ + { + name: 'siteId', + type: 'Path', + schema: z.string(), + }, + { + name: 'filter', + type: 'Query', + schema: z.string().optional(), + description: 'Filter expression (e.g., name:eq:SalesFlow)', + }, + { + name: 'sort', + type: 'Query', + schema: z.string().optional(), + description: 'Sort expression (e.g., createdAt:desc)', + }, + { + name: 'page-size', + type: 'Query', + schema: z.number().optional(), + description: + 'The number of items to return in one response. The minimum is 1. The maximum is 1000. The default is 100.', + }, + { + name: 'page-number', + type: 'Query', + schema: z.number().optional(), + description: 'The offset for paging. The default is 1.', + }, + ], + response: z.object({ + pagination: paginationSchema, + flows: z.object({ + flow: z.optional(z.array(flowSchema)), + }), + }), +}); + +const flowsApi = makeApi([listFlowsRestEndpoint]); +export const flowsApis = [...flowsApi] as const satisfies ZodiosEndpointDefinitions; diff --git a/src/sdks/tableau/methods/flowsMethods.ts b/src/sdks/tableau/methods/flowsMethods.ts new file mode 100644 index 00000000..3b7325ca --- /dev/null +++ b/src/sdks/tableau/methods/flowsMethods.ts @@ -0,0 +1,45 @@ +import { Zodios } from '@zodios/core'; + +import { Flow, flowsApis } from '../apis/flowsApi.js'; +import { Credentials } from '../types/credentials.js'; +import { Pagination } from '../types/pagination.js'; +import AuthenticatedMethods from './authenticatedMethods.js'; + +export default class FlowsMethods extends AuthenticatedMethods { + constructor(baseUrl: string, creds: Credentials) { + super(new Zodios(baseUrl, flowsApis), creds); + } + + /** + * Returns a list of flows on the specified site. + * @link https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#query_flows_for_site + * @param siteId - The Tableau site ID + * @param filter - The filter expression (e.g., name:eq:SalesFlow) + * @param sort - The sort expression (e.g., createdAt:desc) + * @param pageSize - The number of items to return in one response. The minimum is 1. The maximum is 1000. The default is 100. + * @param pageNumber - The offset for paging. The default is 1. + */ + listFlows = async ({ + siteId, + filter, + sort, + pageSize, + pageNumber, + }: { + siteId: string; + filter?: string; + sort?: string; + pageSize?: number; + pageNumber?: number; + }): Promise<{ pagination: Pagination; flows: Flow[] }> => { + const response = await this._apiClient.listFlows({ + params: { siteId }, + queries: { filter, sort, 'page-size': pageSize, 'page-number': pageNumber }, + ...this.authHeader, + }); + return { + pagination: response.pagination, + flows: response.flows.flow ?? [], + }; + }; +} diff --git a/src/sdks/tableau/restApi.ts b/src/sdks/tableau/restApi.ts index b8492a01..cf2440e6 100644 --- a/src/sdks/tableau/restApi.ts +++ b/src/sdks/tableau/restApi.ts @@ -12,6 +12,7 @@ import DatasourcesMethods from './methods/datasourcesMethods.js'; import MetadataMethods from './methods/metadataMethods.js'; import VizqlDataServiceMethods from './methods/vizqlDataServiceMethods.js'; import { Credentials } from './types/credentials.js'; +import FlowsMethods from './methods/flowsMethods.js'; /** * Interface for the Tableau REST APIs @@ -27,6 +28,7 @@ export default class RestApi { private _datasourcesMethods?: DatasourcesMethods; private _metadataMethods?: MetadataMethods; private _vizqlDataServiceMethods?: VizqlDataServiceMethods; + private _flowsMethods?: FlowsMethods; private static _version = '3.24'; private _requestInterceptor?: [RequestInterceptor, ErrorInterceptor?]; @@ -86,6 +88,14 @@ export default class RestApi { return this._vizqlDataServiceMethods; } + get flowsMethods(): FlowsMethods { + if (!this._flowsMethods) { + this._flowsMethods = new FlowsMethods(this._baseUrl, this.creds); + this._addInterceptors(this._baseUrl, this._flowsMethods.interceptors); + } + return this._flowsMethods; + } + signIn = async (authConfig: AuthConfig): Promise => { const authenticationMethods = new AuthenticationMethods(this._baseUrl); this._addInterceptors(this._baseUrl, authenticationMethods.interceptors); diff --git a/src/sdks/tableau/types/flow.ts b/src/sdks/tableau/types/flow.ts new file mode 100644 index 00000000..1f94f481 --- /dev/null +++ b/src/sdks/tableau/types/flow.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +import { flowParamsSchema } from '../types/flowParameter.js'; +import { ownerSchema } from '../types/owner.js'; +import { projectSchema } from '../types/project.js'; +import { tagsSchema } from '../types/tag.js'; + +export const flowSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), + webpageUrl: z.string(), + fileType: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + project: projectSchema, + owner: ownerSchema, + tags: tagsSchema.optional(), + parameters: flowParamsSchema.optional(), +}); + +export type Flow = z.infer; diff --git a/src/sdks/tableau/types/flowParameter.ts b/src/sdks/tableau/types/flowParameter.ts new file mode 100644 index 00000000..20151e80 --- /dev/null +++ b/src/sdks/tableau/types/flowParameter.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +export const flowParameterSchema = z.object({ + id: z.string(), + type: z.string(), + name: z.string(), + description: z.string().optional(), + value: z.string().optional(), + isRequired: z.coerce.boolean(), + domain: z.object({ + domainType: z.string(), + values: z.object({ + value: z.array(z.string()), + }).optional(), + }).optional(), +}); + +export type FlowParameter = z.infer; + +export const flowParamsSchema = z.object({ + parameter: z.array(flowParameterSchema).optional(), +}); + +export type FlowParams = z.infer; diff --git a/src/sdks/tableau/types/owner.ts b/src/sdks/tableau/types/owner.ts new file mode 100644 index 00000000..f60f1174 --- /dev/null +++ b/src/sdks/tableau/types/owner.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const ownerSchema = z.object({ + id: z.string(), +}); + +export type Owner = z.infer; diff --git a/src/sdks/tableau/types/project.ts b/src/sdks/tableau/types/project.ts new file mode 100644 index 00000000..2354c716 --- /dev/null +++ b/src/sdks/tableau/types/project.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const projectSchema = z.object({ + name: z.string(), + id: z.string(), +}); + +export type Project = z.infer; diff --git a/src/sdks/tableau/types/tag.ts b/src/sdks/tableau/types/tag.ts new file mode 100644 index 00000000..207b6d6a --- /dev/null +++ b/src/sdks/tableau/types/tag.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const tagSchema = z.object({ + label: z.string(), +}); + +export type Tag = z.infer; + +export const tagsSchema = z.object({ + tag: z.array(tagSchema).optional(), +}); + +export type Tags = z.infer; diff --git a/src/tools/listFlows/flowsFilterUtils.test.ts b/src/tools/listFlows/flowsFilterUtils.test.ts new file mode 100644 index 00000000..b2007f0d --- /dev/null +++ b/src/tools/listFlows/flowsFilterUtils.test.ts @@ -0,0 +1,29 @@ +import { parseAndValidateFlowFilterString } from './flowsFilterUtils.js'; + +describe('parseAndValidateFlowFilterString', () => { + it('should return the filter string if valid (single expression)', () => { + expect(parseAndValidateFlowFilterString('name:eq:SalesFlow')).toBe('name:eq:SalesFlow'); + expect(parseAndValidateFlowFilterString('createdAt:gt:2023-01-01T00:00:00Z')).toBe('createdAt:gt:2023-01-01T00:00:00Z'); + }); + + it('should return the filter string if valid (multiple expressions)', () => { + const filter = 'name:eq:SalesFlow,tags:in:tag1|tag2,createdAt:gte:2023-01-01T00:00:00Z'; + expect(parseAndValidateFlowFilterString(filter)).toBe(filter); + }); + + it('should throw if field is not supported', () => { + expect(() => parseAndValidateFlowFilterString('foo:eq:bar')).toThrow('Unsupported filter field: foo'); + }); + + it('should throw if operator is not supported', () => { + expect(() => parseAndValidateFlowFilterString('name:like:SalesFlow')).toThrow('Unsupported filter operator: like'); + }); + + it('should throw if value is missing', () => { + expect(() => parseAndValidateFlowFilterString('name:eq')).toThrow('Missing value for filter: name:eq'); + }); + + it('should return undefined if filter is undefined', () => { + expect(parseAndValidateFlowFilterString(undefined)).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/src/tools/listFlows/flowsFilterUtils.ts b/src/tools/listFlows/flowsFilterUtils.ts new file mode 100644 index 00000000..b93594a4 --- /dev/null +++ b/src/tools/listFlows/flowsFilterUtils.ts @@ -0,0 +1,27 @@ +// Filter validation utility for flows + +const SUPPORTED_FIELDS = [ + 'name', 'tags', 'createdAt', +]; +const SUPPORTED_OPERATORS = [ + 'eq', 'in', 'gt', 'gte', 'lt', 'lte', +]; + +export function parseAndValidateFlowFilterString(filter?: string): string | undefined { + if (!filter) return undefined; + // Simple validation example (extend as needed) + const expressions = filter.split(','); + for (const expr of expressions) { + const [field, operator, ...rest] = expr.split(':'); + if (!SUPPORTED_FIELDS.includes(field)) { + throw new Error(`Unsupported filter field: ${field}`); + } + if (!SUPPORTED_OPERATORS.includes(operator)) { + throw new Error(`Unsupported filter operator: ${operator}`); + } + if (rest.length === 0) { + throw new Error(`Missing value for filter: ${expr}`); + } + } + return filter; +} \ No newline at end of file diff --git a/src/tools/listFlows/listFlows.test.ts b/src/tools/listFlows/listFlows.test.ts new file mode 100644 index 00000000..b5c9478d --- /dev/null +++ b/src/tools/listFlows/listFlows.test.ts @@ -0,0 +1,130 @@ +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; + +import { server } from '../../server.js'; +import { listFlowsTool } from './listFlows.js'; + +// Mock server.server.sendLoggingMessage since the transport won't be connected. +vi.spyOn(server.server, 'sendLoggingMessage').mockImplementation(vi.fn()); + +const mockFlows = { + pagination: { + pageNumber: 1, + pageSize: 10, + totalAvailable: 2, + }, + flows: [ + { + id: 'flow1', + name: 'SalesFlow', + description: 'desc1', + project: { name: 'Samples', id: 'proj1' }, + owner: { id: 'owner1' }, + webpageUrl: 'http://example.com/flow1', + fileType: 'tfl', + createdAt: '2023-01-01T00:00:00Z', + updatedAt: '2023-01-02T00:00:00Z', + tags: { tag: [{ label: 'tag1' }] }, + parameters: { parameter: [] }, + }, + { + id: 'flow2', + name: 'FinanceFlow', + description: 'desc2', + project: { name: 'Finance', id: 'proj2' }, + owner: { id: 'owner2' }, + webpageUrl: 'http://example.com/flow2', + fileType: 'tfl', + createdAt: '2023-01-03T00:00:00Z', + updatedAt: '2023-01-04T00:00:00Z', + tags: { tag: [{ label: 'tag2' }] }, + parameters: { parameter: [] }, + }, + ], +}; + +const mocks = vi.hoisted(() => ({ + mockListFlows: vi.fn(), +})); + +vi.mock('../../restApiInstance.js', () => ({ + getNewRestApiInstanceAsync: vi.fn().mockResolvedValue({ + flowsMethods: { + listFlows: mocks.mockListFlows, + }, + siteId: 'test-site-id', + }), +})); + +describe('listFlowsTool', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should create a tool instance with correct properties', () => { + expect(listFlowsTool.name).toBe('list-flows'); + expect(listFlowsTool.description).toContain('Retrieves a list of published Tableau Prep flows'); + expect(listFlowsTool.paramsSchema).toMatchObject({ filter: expect.any(Object) }); + }); + + it('should successfully list flows (filter only)', async () => { + mocks.mockListFlows.mockResolvedValue(mockFlows); + const result = await getToolResult({ filter: 'name:eq:SalesFlow' }); + expect(result.isError).toBe(false); + expect(result.content[0].text).toContain('SalesFlow'); + expect(mocks.mockListFlows).toHaveBeenCalledWith({ + siteId: 'test-site-id', + filter: 'name:eq:SalesFlow', + sort: undefined, + pageSize: undefined, + pageNumber: undefined, + }); + }); + + it('should successfully list flows with sort, pageSize, and limit', async () => { + mocks.mockListFlows.mockResolvedValue(mockFlows); + const result = await getToolResult({ + filter: 'name:eq:SalesFlow', + sort: 'createdAt:desc', + pageSize: 5, + limit: 10, + }); + expect(result.isError).toBe(false); + expect(mocks.mockListFlows).toHaveBeenCalledWith({ + siteId: 'test-site-id', + filter: 'name:eq:SalesFlow', + sort: 'createdAt:desc', + pageSize: 5, + pageNumber: undefined, + }); + }); + + it('should handle API errors gracefully', async () => { + const errorMessage = 'API Error'; + mocks.mockListFlows.mockRejectedValue(new Error(errorMessage)); + const result = await getToolResult({ filter: 'name:eq:SalesFlow' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain(errorMessage); + }); + + it('should handle empty filter (list all)', async () => { + mocks.mockListFlows.mockResolvedValue(mockFlows); + const result = await getToolResult({}); + expect(result.isError).toBe(false); + expect(mocks.mockListFlows).toHaveBeenCalledWith({ + siteId: 'test-site-id', + filter: '', + sort: undefined, + pageSize: undefined, + pageNumber: undefined, + }); + }); +}); + +async function getToolResult(params: any): Promise { + return await listFlowsTool.callback(params, { + signal: new AbortController().signal, + requestId: 'test-request-id', + sendNotification: vi.fn(), + sendRequest: vi.fn(), + }); +} diff --git a/src/tools/listFlows/listFlows.ts b/src/tools/listFlows/listFlows.ts new file mode 100644 index 00000000..c6dc044c --- /dev/null +++ b/src/tools/listFlows/listFlows.ts @@ -0,0 +1,74 @@ +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { Ok } from 'ts-results-es'; +import { z } from 'zod'; + +import { getConfig } from '../../config.js'; +import { getNewRestApiInstanceAsync } from '../../restApiInstance.js'; +import { paginate } from '../../utils/paginate.js'; +import { Tool } from '../tool.js'; +import { parseAndValidateFlowFilterString } from './flowsFilterUtils.js'; + +export const listFlowsTool = new Tool({ + name: 'list-flows', + description: ` +Retrieves a list of published Tableau Prep flows from a specified Tableau site using the Tableau REST API. Supports optional filtering via field:operator:value expressions (e.g., name:eq:SalesFlow) for precise and flexible flow discovery. Use this tool when a user requests to list, search, or filter Tableau Prep flows on a site. + +**Supported Filter Fields and Operators** +- name, tags, createdAt etc. (according to Tableau REST API spec) +- eq, in, gt, gte, lt, lte etc. + +**Example Usage:** +- List all flows on a site +- List flows with the name "SalesFlow": + filter: "name:eq:SalesFlow" +- List flows created after January 1, 2023: + filter: "createdAt:gt:2023-01-01T00:00:00Z" +`, + paramsSchema: { + filter: z.string().optional(), + sort: z.string().optional(), + pageSize: z.number().gt(0).optional(), + limit: z.number().gt(0).optional(), + }, + annotations: { + title: 'List Flows', + readOnlyHint: true, + openWorldHint: false, + }, + callback: async ({ filter, sort, pageSize, limit }, { requestId }): Promise => { + const config = getConfig(); + const validatedFilter = filter ? parseAndValidateFlowFilterString(filter) : undefined; + return await listFlowsTool.logAndExecute({ + requestId, + args: { filter, sort, pageSize, limit }, + callback: async () => { + const restApi = await getNewRestApiInstanceAsync( + config.server, + config.authConfig, + requestId, + ); + + const flows = await paginate({ + pageConfig: { + pageSize, + limit: config.maxResultLimit + ? Math.min(config.maxResultLimit, limit ?? Number.MAX_SAFE_INTEGER) + : limit, + }, + getDataFn: async (pageConfig) => { + const { pagination, flows: data } = await restApi.flowsMethods.listFlows({ + siteId: restApi.siteId, + filter: validatedFilter ?? '', + sort, + pageSize: pageConfig.pageSize, + pageNumber: pageConfig.pageNumber, + }); + return { pagination, data }; + }, + }); + + return new Ok(flows); + }, + }); + }, +}); \ No newline at end of file diff --git a/src/tools/toolName.ts b/src/tools/toolName.ts index e094aabd..aee334d7 100644 --- a/src/tools/toolName.ts +++ b/src/tools/toolName.ts @@ -3,6 +3,7 @@ export const toolNames = [ 'list-fields', 'query-datasource', 'read-metadata', + 'list-flows', ] as const; export type ToolName = (typeof toolNames)[number]; diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 6aca923c..dfb4738b 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -2,5 +2,12 @@ import { listDatasourcesTool } from './listDatasources/listDatasources.js'; import { listFieldsTool } from './listFields.js'; import { queryDatasourceTool } from './queryDatasource/queryDatasource.js'; import { readMetadataTool } from './readMetadata.js'; +import { listFlowsTool } from './listFlows/listFlows.js'; -export const tools = [listDatasourcesTool, listFieldsTool, queryDatasourceTool, readMetadataTool]; +export const tools = [ + listDatasourcesTool + , listFieldsTool + , queryDatasourceTool + , readMetadataTool + , listFlowsTool +];