diff --git a/README.md b/README.md index d5be69e3..b03f0b36 100644 --- a/README.md +++ b/README.md @@ -196,17 +196,17 @@ These config files will be used in tool configuration explained below. #### Optional Environment Variables -| **Variable** | **Description** | **Default** | **Note** | -| -------------------------------------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `TRANSPORT` | The MCP transport type to use for the server. | `stdio` | Possible values are `stdio` or `http`. For `http`, see [HTTP Server Configuration](#http-server-configuration) below for additional variables. See [Transports][mcp-transport] for details. | -| `AUTH` | The authentication method to use by the server. | `pat` | Possible values are `pat` or `direct-trust`. See below sections for additional required variables depending on the desired method. | -| `DEFAULT_LOG_LEVEL` | The default logging level of the server. | `debug` | | -| `DATASOURCE_CREDENTIALS` | A JSON string that includes usernames and passwords for any datasources that require them. | Empty string | Format is provided in the [DATASOURCE_CREDENTIALS](#datasource_credentials) section below. | -| `DISABLE_LOG_MASKING` | Disable masking of credentials in logs. For debug purposes only. | `false` | | -| `INCLUDE_TOOLS` | A comma-separated list of tool names to include in the server. Only these tools will be available. | Empty string (_all_ are included) | For a list of available tools, see [toolName.ts](src/tools/toolName.ts). | -| `EXCLUDE_TOOLS` | A comma-separated list of tool names to exclude from the server. All other tools will be available. | Empty string (_none_ are excluded) | Cannot be provided with `INCLUDE_TOOLS`. | -| `MAX_RESULT_LIMIT` | If a tool has a "limit" parameter and returns an array of items, the maximum length of that array. | Empty string (_no limit_) | A positive number. | -| `DISABLE_QUERY_DATASOURCE_FILTER_VALIDATION` | Disable validation of SET and MATCH filter values in query-datasource tool. | `false` | When `true`, skips validation that checks if filter values exist in the target field. | +| **Variable** | **Description** | **Default** | **Note** | +| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `TRANSPORT` | The MCP transport type to use for the server. | `stdio` | Possible values are `stdio` or `http`. For `http`, see [HTTP Server Configuration](#http-server-configuration) below for additional variables. See [Transports][mcp-transport] for details. | +| `AUTH` | The authentication method to use by the server. | `pat` | Possible values are `pat` or `direct-trust`. See below sections for additional required variables depending on the desired method. | +| `DEFAULT_LOG_LEVEL` | The default logging level of the server. | `debug` | | +| `DATASOURCE_CREDENTIALS` | A JSON string that includes usernames and passwords for any datasources that require them. | Empty string | Format is provided in the [DATASOURCE_CREDENTIALS](#datasource_credentials) section below. | +| `DISABLE_LOG_MASKING` | Disable masking of credentials in logs. For debug purposes only. | `false` | | +| `INCLUDE_TOOLS` | A comma-separated list of tool or tool group names to include in the server. Only these tools will be available. | Empty string (_all_ are included) | For a list of available tools and groups, see [toolName.ts](src/tools/toolName.ts). Mixing tool names and group names is allowed. | +| `EXCLUDE_TOOLS` | A comma-separated list of tool or tool group names to exclude from the server. All other tools will be available. | Empty string (_none_ are excluded) | Cannot be provided with `INCLUDE_TOOLS`. | +| `MAX_RESULT_LIMIT` | If a tool has a "limit" parameter and returns an array of items, the maximum length of that array. | Empty string (_no limit_) | A positive number. | +| `DISABLE_QUERY_DATASOURCE_FILTER_VALIDATION` | Disable validation of SET and MATCH filter values in query-datasource tool. | `false` | When `true`, skips validation that checks if filter values exist in the target field. | #### HTTP Server Configuration diff --git a/package-lock.json b/package-lock.json index 349db71f..9ffaa91b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tableau-mcp", - "version": "1.6.0", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tableau-mcp", - "version": "1.6.0", + "version": "1.7.0", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", diff --git a/package.json b/package.json index 889b5aec..fade99bf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tableau-mcp", "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.6.0", + "version": "1.7.0", "homepage": "https://github.com/tableau/tableau-mcp", "bugs": "https://github.com/tableau/tableau-mcp/issues", "author": "Tableau", diff --git a/src/config.test.ts b/src/config.test.ts index f33fd7e8..89c61a08 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -277,6 +277,17 @@ describe('Config', () => { expect(config.includeTools).toEqual(['query-datasource', 'list-fields']); }); + it('should parse INCLUDE_TOOLS into an array of valid tool names when tool group names are used', () => { + process.env = { + ...process.env, + ...defaultEnvVars, + INCLUDE_TOOLS: 'query-datasource,workbook', + }; + + const config = new Config(); + expect(config.includeTools).toEqual(['query-datasource', 'list-workbooks', 'get-workbook']); + }); + it('should parse EXCLUDE_TOOLS into an array of valid tool names', () => { process.env = { ...process.env, @@ -288,6 +299,17 @@ describe('Config', () => { expect(config.excludeTools).toEqual(['query-datasource']); }); + it('should parse EXCLUDE_TOOLS into an array of valid tool names when tool group names are used', () => { + process.env = { + ...process.env, + ...defaultEnvVars, + EXCLUDE_TOOLS: 'query-datasource,workbook', + }; + + const config = new Config(); + expect(config.excludeTools).toEqual(['query-datasource', 'list-workbooks', 'get-workbook']); + }); + it('should filter out invalid tool names from INCLUDE_TOOLS', () => { process.env = { ...process.env, @@ -318,7 +340,17 @@ describe('Config', () => { EXCLUDE_TOOLS: 'list-fields', }; - expect(() => new Config()).toThrow('Cannot specify both INCLUDE_TOOLS and EXCLUDE_TOOLS'); + expect(() => new Config()).toThrow('Cannot include and exclude tools simultaneously'); + }); + + it('should throw error when both INCLUDE_TOOLS and EXCLUDE_TOOLS are specified with tool group names', () => { + process.env = { + ...process.env, + ...defaultEnvVars, + INCLUDE_TOOLS: 'datasource', + EXCLUDE_TOOLS: 'workbook', + }; + expect(() => new Config()).toThrow('Cannot include and exclude tools simultaneously'); }); }); diff --git a/src/config.ts b/src/config.ts index ab006c83..09c1c578 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,13 @@ import { CorsOptions } from 'cors'; -import { isToolName, ToolName } from './tools/toolName.js'; +import { + isToolGroupName, + isToolName, + isToolRegistrationMode, + toolGroups, + ToolName, + ToolRegistrationMode, +} from './tools/toolName.js'; import { isTransport, TransportName } from './transports.js'; import invariant from './utils/invariant.js'; @@ -28,6 +35,7 @@ export class Config { disableLogMasking: boolean; includeTools: Array; excludeTools: Array; + toolRegistrationMode: ToolRegistrationMode; maxResultLimit: number | null; disableQueryDatasourceFilterValidation: boolean; @@ -54,6 +62,7 @@ export class Config { DISABLE_LOG_MASKING: disableLogMasking, INCLUDE_TOOLS: includeTools, EXCLUDE_TOOLS: excludeTools, + TOOL_REGISTRATION_MODE: toolRegistrationMode, MAX_RESULT_LIMIT: maxResultLimit, DISABLE_QUERY_DATASOURCE_FILTER_VALIDATION: disableQueryDatasourceFilterValidation, } = cleansedVars; @@ -73,27 +82,51 @@ export class Config { this.defaultLogLevel = defaultLogLevel ?? 'debug'; this.disableLogMasking = disableLogMasking === 'true'; this.disableQueryDatasourceFilterValidation = disableQueryDatasourceFilterValidation === 'true'; + this.toolRegistrationMode = isToolRegistrationMode(toolRegistrationMode) + ? toolRegistrationMode + : 'auto'; const maxResultLimitNumber = maxResultLimit ? parseInt(maxResultLimit) : NaN; this.maxResultLimit = isNaN(maxResultLimitNumber) || maxResultLimitNumber <= 0 ? null : maxResultLimitNumber; - this.includeTools = includeTools - ? includeTools - .split(',') - .map((s) => s.trim()) - .filter(isToolName) - : []; - - this.excludeTools = excludeTools - ? excludeTools - .split(',') - .map((s) => s.trim()) - .filter(isToolName) - : []; + if (this.toolRegistrationMode === 'task') { + this.includeTools = ['start-task']; + this.excludeTools = []; + + if (includeTools) { + throw new Error( + 'The environment variable INCLUDE_TOOLS cannot be set when tool registration mode is "task"', + ); + } + + if (excludeTools) { + throw new Error( + 'The environment variable EXCLUDE_TOOLS cannot be set when tool registration mode is "task"', + ); + } + } else { + this.includeTools = includeTools + ? includeTools.split(',').flatMap((s) => { + const v = s.trim(); + return isToolName(v) && v !== 'start-task' && v !== 'complete-task' + ? v + : isToolGroupName(v) + ? toolGroups[v] + : []; + }) + : []; + + this.excludeTools = excludeTools + ? excludeTools.split(',').flatMap((s) => { + const v = s.trim(); + return isToolName(v) ? v : isToolGroupName(v) ? toolGroups[v] : []; + }) + : []; + } if (this.includeTools.length > 0 && this.excludeTools.length > 0) { - throw new Error('Cannot specify both INCLUDE_TOOLS and EXCLUDE_TOOLS'); + throw new Error('Cannot include and exclude tools simultaneously'); } invariant(server, 'The environment variable SERVER is not set'); diff --git a/src/scripts/createClaudeDesktopExtensionManifest.ts b/src/scripts/createClaudeDesktopExtensionManifest.ts index b0a236e3..07927601 100644 --- a/src/scripts/createClaudeDesktopExtensionManifest.ts +++ b/src/scripts/createClaudeDesktopExtensionManifest.ts @@ -150,6 +150,14 @@ const envVars = { required: false, sensitive: false, }, + TOOL_REGISTRATION_MODE: { + includeInUserConfig: false, + type: 'string', + title: 'Tool Registration Mode', + description: 'Set to "task" to enable task mode.', + required: false, + sensitive: false, + }, MAX_RESULT_LIMIT: { includeInUserConfig: false, type: 'number', diff --git a/src/server.test.ts b/src/server.test.ts index a71ce570..39075ef3 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -28,6 +28,10 @@ describe('server', () => { const tools = toolFactories.map((tool) => tool(server)); for (const tool of tools) { + if (tool.name === 'complete-task' || tool.name === 'start-task') { + continue; + } + expect(server.tool).toHaveBeenCalledWith( tool.name, tool.description, diff --git a/src/server.ts b/src/server.ts index 27e8c320..f017c960 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,11 +1,11 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; import { SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import pkg from '../package.json' with { type: 'json' }; import { getConfig } from './config.js'; import { setLogLevel } from './logging/log.js'; import { Tool } from './tools/tool.js'; -import { toolNames } from './tools/toolName.js'; +import { ToolName, toolNames } from './tools/toolName.js'; import { toolFactories } from './tools/tools.js'; export const serverName = pkg.name; @@ -14,6 +14,7 @@ export const serverVersion = pkg.version; export class Server extends McpServer { readonly name: string; readonly version: string; + readonly registeredTools: Map = new Map(); constructor() { super( @@ -24,7 +25,9 @@ export class Server extends McpServer { { capabilities: { logging: {}, - tools: {}, + tools: { + listChanged: getConfig().toolRegistrationMode === 'task', + }, }, }, ); @@ -33,15 +36,24 @@ export class Server extends McpServer { this.version = serverVersion; } - registerTools = (): void => { + registerTools = ( + overrides?: Partial<{ + includeTools: Array; + excludeTools: Array; + }>, + ): void => { + this.registeredTools.forEach((tool) => tool.remove()); + this.registeredTools.clear(); + for (const { name, description, paramsSchema, annotations, callback, - } of this._getToolsToRegister()) { - this.tool(name, description, paramsSchema, annotations, callback); + } of this._getToolsToRegister(overrides)) { + const tool = this.tool(name, description, paramsSchema, annotations, callback); + this.registeredTools.set(name, tool); } }; @@ -52,8 +64,16 @@ export class Server extends McpServer { }); }; - private _getToolsToRegister = (): Array> => { - const { includeTools, excludeTools } = getConfig(); + private _getToolsToRegister = ( + overrides?: Partial<{ + includeTools: Array; + excludeTools: Array; + }>, + ): Array> => { + const config = getConfig(); + let { includeTools, excludeTools } = overrides ?? config; + includeTools = includeTools ?? config.includeTools; + excludeTools = excludeTools ?? config.excludeTools; const tools = toolFactories.map((tool) => tool(this)); const toolsToRegister = tools.filter((tool) => { @@ -65,6 +85,10 @@ export class Server extends McpServer { return !excludeTools.includes(tool.name); } + if (tool.name === 'start-task' || tool.name === 'complete-task') { + return false; + } + return true; }); diff --git a/src/tools/completeTask.ts b/src/tools/completeTask.ts new file mode 100644 index 00000000..ac6824ee --- /dev/null +++ b/src/tools/completeTask.ts @@ -0,0 +1,36 @@ +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { Ok } from 'ts-results-es'; + +import { Server } from '../server.js'; +import { Tool } from './tool.js'; + +const paramsSchema = {}; + +export const getCompleteTaskTool = (server: Server): Tool => { + const completeTaskTool = new Tool({ + server, + name: 'complete-task', + description: `Completes a task.`, + paramsSchema, + annotations: { + title: 'Complete Task', + readOnlyHint: true, + openWorldHint: false, + }, + callback: async (_, { requestId }): Promise => { + return await completeTaskTool.logAndExecute({ + requestId, + args: {}, + callback: async () => { + server.registerTools({ + includeTools: ['start-task'], + }); + + return new Ok('success'); + }, + }); + }, + }); + + return completeTaskTool; +}; diff --git a/src/tools/startTask.ts b/src/tools/startTask.ts new file mode 100644 index 00000000..91db33a5 --- /dev/null +++ b/src/tools/startTask.ts @@ -0,0 +1,50 @@ +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { Err, Ok } from 'ts-results-es'; +import { z } from 'zod'; + +import { Server } from '../server.js'; +import { isTaskName, taskNames, taskNamesToTools } from './taskName.js'; +import { Tool } from './tool.js'; +import { isToolGroupName, isToolName, toolGroups } from './toolName.js'; + +const paramsSchema = { + taskName: z.enum(taskNames), +}; + +export const getStartTaskTool = (server: Server): Tool => { + const startTaskTool = new Tool({ + server, + name: 'start-task', + description: `Starts a task with the specified name.`, + paramsSchema, + annotations: { + title: 'Start Task', + readOnlyHint: true, + openWorldHint: false, + }, + callback: async ({ taskName }, { requestId }): Promise => { + return await startTaskTool.logAndExecute({ + requestId, + args: { taskName }, + callback: async () => { + if (!isTaskName(taskName)) { + return new Err(`Invalid task name, must be one of: ${taskNames.join(', ')}`); + } + + const includeTools = taskNamesToTools[taskName].flatMap((toolName) => + isToolName(toolName) ? toolName : isToolGroupName(toolName) ? toolGroups[toolName] : [], + ); + + server.registerTools({ + includeTools: [...includeTools, 'complete-task'], + }); + + return new Ok('success'); + }, + getErrorText: (error) => error, + }); + }, + }); + + return startTaskTool; +}; diff --git a/src/tools/taskName.ts b/src/tools/taskName.ts new file mode 100644 index 00000000..bb1a740d --- /dev/null +++ b/src/tools/taskName.ts @@ -0,0 +1,33 @@ +import { ToolGroupName, ToolName } from './toolName.js'; + +export const taskNames = [ + 'Data Analysis', + 'Workbook Visualization', + 'Content Management', + 'Pulse', +] as const; +export type TaskName = (typeof taskNames)[number]; + +export const taskNamesToTools = { + 'Data Analysis': ['list-datasources', 'list-fields', 'query-datasource', 'read-metadata'], + 'Workbook Visualization': [ + 'list-workbooks', + 'get-workbook', + 'list-views', + 'get-view-data', + 'get-view-image', + ], + 'Content Management': ['workbook', 'view', 'list-datasources'], + Pulse: [ + 'list-all-pulse-metric-definitions', + 'list-pulse-metric-definitions-from-definition-ids', + 'list-pulse-metrics-from-metric-definition-id', + 'list-pulse-metrics-from-metric-ids', + 'list-pulse-metric-subscriptions', + 'generate-pulse-metric-value-insight-bundle', + ], +} as const satisfies Record>; + +export function isTaskName(value: unknown): value is TaskName { + return !!taskNames.find((name) => name === value); +} diff --git a/src/tools/toolName.test.ts b/src/tools/toolName.test.ts new file mode 100644 index 00000000..0742997f --- /dev/null +++ b/src/tools/toolName.test.ts @@ -0,0 +1,45 @@ +import { describe, it } from 'vitest'; + +import { + isToolGroupName, + isToolName, + ToolGroupName, + toolGroupNames, + toolGroups, + ToolName, + toolNames, +} from './toolName.js'; + +describe('toolName', () => { + it('should validate each tool belongs to a group', () => { + const toolNamesToGroups = Object.entries(toolGroups).reduce( + (acc, [group, tools]) => { + for (const tool of tools) { + if (isToolName(tool) && isToolGroupName(group)) { + if (acc[tool]) { + acc[tool].add(group); + } else { + acc[tool] = new Set([group]); + } + } + } + return acc; + }, + {} as Record>, + ); + + for (const toolName of toolNames) { + if (toolName === 'start-task' || toolName === 'complete-task') { + continue; + } + + expect(toolNamesToGroups[toolName], `Tool ${toolName} is not in a group`).toBeDefined(); + } + }); + + it('should not allow a tool group to have the same name as a tool', () => { + for (const group of toolGroupNames) { + expect(isToolName(group), `Group ${group} is the same as a tool name`).toBe(false); + } + }); +}); diff --git a/src/tools/toolName.ts b/src/tools/toolName.ts index b1ff2b34..9decf297 100644 --- a/src/tools/toolName.ts +++ b/src/tools/toolName.ts @@ -1,4 +1,6 @@ export const toolNames = [ + 'start-task', + 'complete-task', 'list-datasources', 'list-fields', 'list-workbooks', @@ -17,6 +19,34 @@ export const toolNames = [ ] as const; export type ToolName = (typeof toolNames)[number]; +export const toolGroupNames = ['datasource', 'workbook', 'view', 'pulse'] as const; +export type ToolGroupName = (typeof toolGroupNames)[number]; + +export const toolGroups = { + datasource: ['list-datasources', 'list-fields', 'query-datasource', 'read-metadata'], + workbook: ['list-workbooks', 'get-workbook'], + view: ['list-views', 'get-view-data', 'get-view-image'], + pulse: [ + 'list-all-pulse-metric-definitions', + 'list-pulse-metric-definitions-from-definition-ids', + 'list-pulse-metrics-from-metric-definition-id', + 'list-pulse-metrics-from-metric-ids', + 'list-pulse-metric-subscriptions', + 'generate-pulse-metric-value-insight-bundle', + ], +} as const satisfies Record>; + +export const toolRegistrationModes = ['auto', 'task'] as const; +export type ToolRegistrationMode = (typeof toolRegistrationModes)[number]; + export function isToolName(value: unknown): value is ToolName { return !!toolNames.find((name) => name === value); } + +export function isToolGroupName(value: unknown): value is ToolGroupName { + return !!toolGroupNames.find((name) => name === value); +} + +export function isToolRegistrationMode(value: unknown): value is ToolRegistrationMode { + return !!toolRegistrationModes.find((name) => name === value); +} diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 0bb14f84..3d304af1 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -1,3 +1,4 @@ +import { getCompleteTaskTool } from './completeTask.js'; import { getListDatasourcesTool } from './listDatasources/listDatasources.js'; import { getListFieldsTool } from './listFields.js'; import { getGeneratePulseMetricValueInsightBundleTool } from './pulse/generateMetricValueInsightBundle/generatePulseMetricValueInsightBundleTool.js'; @@ -8,6 +9,7 @@ import { getListPulseMetricsFromMetricIdsTool } from './pulse/listMetricsFromMet import { getListPulseMetricSubscriptionsTool } from './pulse/listMetricSubscriptions/listPulseMetricSubscriptions.js'; import { getQueryDatasourceTool } from './queryDatasource/queryDatasource.js'; import { getReadMetadataTool } from './readMetadata.js'; +import { getStartTaskTool } from './startTask.js'; import { getGetViewDataTool } from './views/getViewData.js'; import { getGetViewImageTool } from './views/getViewImage.js'; import { getListViewsTool } from './views/listViews.js'; @@ -15,6 +17,8 @@ import { getGetWorkbookTool } from './workbooks/getWorkbook.js'; import { getListWorkbooksTool } from './workbooks/listWorkbooks.js'; export const toolFactories = [ + getStartTaskTool, + getCompleteTaskTool, getListDatasourcesTool, getListFieldsTool, getQueryDatasourceTool, diff --git a/types/process-env.d.ts b/types/process-env.d.ts index 8ffb710d..1911b7ee 100644 --- a/types/process-env.d.ts +++ b/types/process-env.d.ts @@ -19,6 +19,7 @@ export interface ProcessEnvEx { DISABLE_LOG_MASKING: string | undefined; INCLUDE_TOOLS: string | undefined; EXCLUDE_TOOLS: string | undefined; + TOOL_REGISTRATION_MODE: string | undefined; MAX_RESULT_LIMIT: string | undefined; DISABLE_QUERY_DATASOURCE_FILTER_VALIDATION: string | undefined; }