From 883993f2c4bb7fda18eb23dc6a4a016580949151 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Mon, 25 Aug 2025 22:17:04 -0700 Subject: [PATCH 1/9] Add tool groupings --- README.md | 22 ++++++++--------- src/config.test.ts | 48 +++++++++++++++++++++++++++++++++++++- src/config.ts | 20 ++++++++-------- src/tools/toolName.test.ts | 30 ++++++++++++++++++++++++ src/tools/toolName.ts | 20 ++++++++++++++++ 5 files changed, 118 insertions(+), 22 deletions(-) create mode 100644 src/tools/toolName.test.ts 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/src/config.test.ts b/src/config.test.ts index f33fd7e8..ce4fda17 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -277,6 +277,24 @@ 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', + 'list-views', + 'get-workbook', + 'get-view-data', + 'get-view-image', + ]); + }); + it('should parse EXCLUDE_TOOLS into an array of valid tool names', () => { process.env = { ...process.env, @@ -288,6 +306,24 @@ 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', + 'list-views', + 'get-workbook', + 'get-view-data', + 'get-view-image', + ]); + }); + it('should filter out invalid tool names from INCLUDE_TOOLS', () => { process.env = { ...process.env, @@ -318,7 +354,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..fcff3a0d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ import { CorsOptions } from 'cors'; -import { isToolName, ToolName } from './tools/toolName.js'; +import { isToolGroupName, isToolName, toolGroups, ToolName } from './tools/toolName.js'; import { isTransport, TransportName } from './transports.js'; import invariant from './utils/invariant.js'; @@ -79,21 +79,21 @@ export class Config { isNaN(maxResultLimitNumber) || maxResultLimitNumber <= 0 ? null : maxResultLimitNumber; this.includeTools = includeTools - ? includeTools - .split(',') - .map((s) => s.trim()) - .filter(isToolName) + ? includeTools.split(',').flatMap((s) => { + const v = s.trim(); + return isToolName(v) ? v : isToolGroupName(v) ? toolGroups[v] : []; + }) : []; this.excludeTools = excludeTools - ? excludeTools - .split(',') - .map((s) => s.trim()) - .filter(isToolName) + ? 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/tools/toolName.test.ts b/src/tools/toolName.test.ts new file mode 100644 index 00000000..8ffd6ab6 --- /dev/null +++ b/src/tools/toolName.test.ts @@ -0,0 +1,30 @@ +import { describe, it } from 'vitest'; + +import { + isToolGroupName, + isToolName, + ToolGroupName, + 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)) { + acc[tool] = group; + } + } + return acc; + }, + {} as Record, + ); + + for (const toolName of toolNames) { + expect(toolNamesToGroups[toolName], `Tool ${toolName} is not in a group`).toBeDefined(); + } + }); +}); diff --git a/src/tools/toolName.ts b/src/tools/toolName.ts index b1ff2b34..750b4049 100644 --- a/src/tools/toolName.ts +++ b/src/tools/toolName.ts @@ -17,6 +17,26 @@ export const toolNames = [ ] as const; export type ToolName = (typeof toolNames)[number]; +export const toolGroupNames = ['datasource', 'workbook', 'pulse'] as const; +export type ToolGroupName = (typeof toolGroupNames)[number]; + +export const toolGroups = { + datasource: ['list-datasources', 'list-fields', 'query-datasource', 'read-metadata'], + workbook: ['list-workbooks', 'list-views', 'get-workbook', '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 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); +} From 83c1bebd5033d461e4cd2c8784354a728888e4bc Mon Sep 17 00:00:00 2001 From: Andy Young Date: Mon, 25 Aug 2025 22:23:29 -0700 Subject: [PATCH 2/9] Add view group --- src/config.test.ts | 18 ++---------------- src/tools/toolName.ts | 5 +++-- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/src/config.test.ts b/src/config.test.ts index ce4fda17..89c61a08 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -285,14 +285,7 @@ describe('Config', () => { }; const config = new Config(); - expect(config.includeTools).toEqual([ - 'query-datasource', - 'list-workbooks', - 'list-views', - 'get-workbook', - 'get-view-data', - 'get-view-image', - ]); + expect(config.includeTools).toEqual(['query-datasource', 'list-workbooks', 'get-workbook']); }); it('should parse EXCLUDE_TOOLS into an array of valid tool names', () => { @@ -314,14 +307,7 @@ describe('Config', () => { }; const config = new Config(); - expect(config.excludeTools).toEqual([ - 'query-datasource', - 'list-workbooks', - 'list-views', - 'get-workbook', - 'get-view-data', - 'get-view-image', - ]); + expect(config.excludeTools).toEqual(['query-datasource', 'list-workbooks', 'get-workbook']); }); it('should filter out invalid tool names from INCLUDE_TOOLS', () => { diff --git a/src/tools/toolName.ts b/src/tools/toolName.ts index 750b4049..05031053 100644 --- a/src/tools/toolName.ts +++ b/src/tools/toolName.ts @@ -17,12 +17,13 @@ export const toolNames = [ ] as const; export type ToolName = (typeof toolNames)[number]; -export const toolGroupNames = ['datasource', 'workbook', 'pulse'] as const; +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', 'list-views', 'get-workbook', 'get-view-data', 'get-view-image'], + 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', From 93e90dcfdbea23ba77e940e148649a668a7add73 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Mon, 25 Aug 2025 22:28:18 -0700 Subject: [PATCH 3/9] Use Set instead of Array --- src/tools/toolName.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/tools/toolName.test.ts b/src/tools/toolName.test.ts index 8ffd6ab6..9e39f5a5 100644 --- a/src/tools/toolName.test.ts +++ b/src/tools/toolName.test.ts @@ -15,12 +15,16 @@ describe('toolName', () => { (acc, [group, tools]) => { for (const tool of tools) { if (isToolName(tool) && isToolGroupName(group)) { - acc[tool] = group; + if (acc[tool]) { + acc[tool].add(group); + } else { + acc[tool] = new Set([group]); + } } } return acc; }, - {} as Record, + {} as Record>, ); for (const toolName of toolNames) { From ae0346672792543d97bdbed763afa25661cfb8aa Mon Sep 17 00:00:00 2001 From: Andy Young Date: Mon, 25 Aug 2025 22:38:23 -0700 Subject: [PATCH 4/9] Add test to prevent name collisions --- src/tools/toolName.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/tools/toolName.test.ts b/src/tools/toolName.test.ts index 9e39f5a5..95258a7b 100644 --- a/src/tools/toolName.test.ts +++ b/src/tools/toolName.test.ts @@ -4,6 +4,7 @@ import { isToolGroupName, isToolName, ToolGroupName, + toolGroupNames, toolGroups, ToolName, toolNames, @@ -31,4 +32,10 @@ describe('toolName', () => { 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); + } + }); }); From 12e546df209047cea724b90370f2efc8de5eb79a Mon Sep 17 00:00:00 2001 From: Andy Young Date: Tue, 26 Aug 2025 08:33:45 -0700 Subject: [PATCH 5/9] Bump version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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", From cf574ebd32f0e180515fbcfd5f8409ee0f9eb844 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Tue, 26 Aug 2025 21:56:33 -0700 Subject: [PATCH 6/9] Add task tools --- src/config.ts | 61 +++++++++++++++++++++++++++++--------- src/server.test.ts | 4 +++ src/server.ts | 36 +++++++++++++++++----- src/tools/completeTask.ts | 36 ++++++++++++++++++++++ src/tools/startTask.ts | 50 +++++++++++++++++++++++++++++++ src/tools/taskName.ts | 33 +++++++++++++++++++++ src/tools/toolName.test.ts | 4 +++ src/tools/toolName.ts | 9 ++++++ src/tools/tools.ts | 4 +++ types/process-env.d.ts | 1 + 10 files changed, 217 insertions(+), 21 deletions(-) create mode 100644 src/tools/completeTask.ts create mode 100644 src/tools/startTask.ts create mode 100644 src/tools/taskName.ts diff --git a/src/config.ts b/src/config.ts index fcff3a0d..09c1c578 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,13 @@ import { CorsOptions } from 'cors'; -import { isToolGroupName, isToolName, toolGroups, 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,24 +82,48 @@ 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(',').flatMap((s) => { - const v = s.trim(); - return isToolName(v) ? 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.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 include and exclude tools simultaneously'); 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..0fca2fa3 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( @@ -33,15 +34,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 +62,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 +83,10 @@ export class Server extends McpServer { return !excludeTools.includes(tool.name); } + if (tool.name === 'start-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 index 95258a7b..0742997f 100644 --- a/src/tools/toolName.test.ts +++ b/src/tools/toolName.test.ts @@ -29,6 +29,10 @@ describe('toolName', () => { ); for (const toolName of toolNames) { + if (toolName === 'start-task' || toolName === 'complete-task') { + continue; + } + expect(toolNamesToGroups[toolName], `Tool ${toolName} is not in a group`).toBeDefined(); } }); diff --git a/src/tools/toolName.ts b/src/tools/toolName.ts index 05031053..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', @@ -34,6 +36,9 @@ export const toolGroups = { ], } 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); } @@ -41,3 +46,7 @@ export function isToolName(value: unknown): value is ToolName { 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; } From 7f321ec498f77b69f9c7d93bc51edb9a40c47dcb Mon Sep 17 00:00:00 2001 From: Andy Young Date: Tue, 26 Aug 2025 22:09:57 -0700 Subject: [PATCH 7/9] Add tools changed capability --- src/server.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/server.ts b/src/server.ts index 0fca2fa3..9c28a55b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -25,7 +25,9 @@ export class Server extends McpServer { { capabilities: { logging: {}, - tools: {}, + tools: { + listChanged: getConfig().toolRegistrationMode === 'task', + }, }, }, ); From b49dc96bf79aa860f50a6c106e5fb05c270404aa Mon Sep 17 00:00:00 2001 From: Andy Young Date: Tue, 26 Aug 2025 22:12:56 -0700 Subject: [PATCH 8/9] Fix manifest --- src/scripts/createClaudeDesktopExtensionManifest.ts | 8 ++++++++ 1 file changed, 8 insertions(+) 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', From bb2f0f6422591afdcb9d03d7b7b5a5c113a32b96 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Tue, 26 Aug 2025 22:15:28 -0700 Subject: [PATCH 9/9] Exclude complete-task tool --- src/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server.ts b/src/server.ts index 9c28a55b..f017c960 100644 --- a/src/server.ts +++ b/src/server.ts @@ -85,7 +85,7 @@ export class Server extends McpServer { return !excludeTools.includes(tool.name); } - if (tool.name === 'start-task') { + if (tool.name === 'start-task' || tool.name === 'complete-task') { return false; }