From 09a797cedc8861307f3ba6d16b5e33a722b88e93 Mon Sep 17 00:00:00 2001 From: Akash Rastogi Date: Thu, 9 Jul 2026 13:16:34 -0700 Subject: [PATCH 1/9] @W-23375797: consolidate admin-gated tools into polymorphic dispatchers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new polymorphic tools reduce the model-visible admin tool surface: - `query-admin-insights` (kind discriminator) merges 4 insight readers: get-stale-workbooks, get-stale-datasources, get-content-usage-metrics, get-user-activity. - `delete-content` (resourceType discriminator) merges 3 preview→confirm destructive tools: delete-workbook, delete-datasource, delete-extract-refresh-task. Behavior preserved end-to-end. `delete-content` records AppApprovalEvidence under legacy namespaces so the mcp-apps HITL panel flow (flag-ON) continues to dispatch confirm-delete-workbook / confirm-delete-datasource / confirm-delete-extract-refresh-task via the existing iframe machinery. The flag-OFF preview→confirm tag/nonce evidence gates work unchanged. Auth: scope-based auth in scopes.ts adds delete-content and query-admin-insights, each carrying the union of its component tools' MCP + REST scopes. Both are dropped from `getEnabledToolNames()` when `ADMIN_TOOLS_ENABLED=false`. Legacy tools remain wired during the shim window. Bumps package version 2.25.0 → 2.26.0. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 4 +- package.json | 2 +- src/server/oauth/scopes.ts | 34 + src/tools/web/_lib/deleteContent.test.ts | 459 +++++++++++++ src/tools/web/_lib/deleteContent.ts | 629 ++++++++++++++++++ src/tools/web/_lib/mutationGuard.ts | 7 +- .../adminInsights/getStaleContentReport.ts | 10 + .../adminInsights/queryAdminInsights.test.ts | 202 ++++++ .../web/adminInsights/queryAdminInsights.ts | 275 ++++++++ src/tools/web/toolName.ts | 5 + src/tools/web/tools.ts | 4 + 11 files changed, 1627 insertions(+), 4 deletions(-) create mode 100644 src/tools/web/_lib/deleteContent.test.ts create mode 100644 src/tools/web/_lib/deleteContent.ts create mode 100644 src/tools/web/adminInsights/queryAdminInsights.test.ts create mode 100644 src/tools/web/adminInsights/queryAdminInsights.ts diff --git a/package-lock.json b/package-lock.json index db0cde6a..79bcbaca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tableau/mcp-server", - "version": "2.25.3", + "version": "2.26.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tableau/mcp-server", - "version": "2.25.3", + "version": "2.26.0", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/ext-apps": "^1.7.2", diff --git a/package.json b/package.json index d1ea440a..1f202c16 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@tableau/mcp-server", "description": "Helping agents see and understand data.", - "version": "2.25.3", + "version": "2.26.0", "repository": { "type": "git", "url": "git+https://github.com/tableau/tableau-mcp.git" diff --git a/src/server/oauth/scopes.ts b/src/server/oauth/scopes.ts index 830203e3..d94b279e 100644 --- a/src/server/oauth/scopes.ts +++ b/src/server/oauth/scopes.ts @@ -330,6 +330,38 @@ const toolScopeMap: Record< 'tableau:users:read', ]), }, + // Consolidated admin-insights tool (W-23375797). Dispatches on `kind` to ts-events, site-content, + // job-performance (raw VDS) or stale-content (server-side anti-join). Union of the scopes required + // by the four legacy tools it replaces — any kind may need any of these. + 'query-admin-insights': { + mcp: ['tableau:mcp:datasource:read'], + api: new Set([ + 'tableau:viz_data_service:read', + 'tableau:content:read', + 'tableau:mcp_site_settings:read', + 'tableau:users:read', + ]), + }, + // Consolidated destructive-delete tool (W-23375797). Dispatches on `resourceType` to workbook, + // datasource, or extract-refresh-task. Union of the MCP + API scopes of the three legacy tools it + // replaces — any dispatch path may need any of these. Workbook and datasource paths still route + // through resourceAccessChecker. + 'delete-content': { + mcp: [ + 'tableau:mcp:workbook:delete', + 'tableau:mcp:datasource:delete', + 'tableau:mcp:tasks:delete', + ], + api: new Set([ + 'tableau:workbooks:delete', + 'tableau:workbook_tags:update', + 'tableau:datasources:delete', + 'tableau:datasource_tags:update', + 'tableau:tasks:delete', + 'tableau:users:read', + ...RESOURCE_ACCESS_CHECKER_REQUIRED_API_SCOPES, + ]), + }, }; function getEnabledToolNames(): Set { @@ -354,6 +386,8 @@ function getEnabledToolNames(): Set { enabledTools.delete('query-admin-insights-site-content'); enabledTools.delete('query-admin-insights-job-performance'); enabledTools.delete('get-stale-content-report'); + enabledTools.delete('query-admin-insights'); + enabledTools.delete('delete-content'); } // Remove the MCP-Apps-only tools if the mcp-apps feature is disabled. The confirm-* tools are the diff --git a/src/tools/web/_lib/deleteContent.test.ts b/src/tools/web/_lib/deleteContent.test.ts new file mode 100644 index 00000000..e374f1b6 --- /dev/null +++ b/src/tools/web/_lib/deleteContent.test.ts @@ -0,0 +1,459 @@ +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { Err, Ok } from 'ts-results-es'; +import type { MockedFunction } from 'vitest'; + +import * as logger from '../../../logging/logger.js'; +import { WebMcpServer } from '../../../server.web.js'; +import invariant from '../../../utils/invariant.js'; +import { Provider } from '../../../utils/provider.js'; +import { getMockRequestHandlerExtra } from '../toolContext.mock.js'; +import { mockWorkbook } from '../workbooks/mockWorkbook.js'; +import { auditRecordSchema } from './auditRecord.js'; +import { getDeleteContentTool } from './deleteContent.js'; +import { AppApprovalEvidence, DEFAULT_PENDING_DELETION_TAG } from './evidence.js'; + +// Auto-mock the logger so the durable audit record emitted by the mutation guard is captured as a +// spy call rather than written to stderr. +vi.mock('../../../logging/logger.js'); + +function getAuditRecords(): ReturnType[] { + const log = logger.log as MockedFunction; + return log.mock.calls + .map((c) => c[0]) + .filter((e) => e.logger === 'audit') + .map((e) => auditRecordSchema.parse(e.data)); +} + +const mockTaggedWorkbook = { + ...mockWorkbook, + tags: { tag: [{ label: DEFAULT_PENDING_DELETION_TAG }] }, +}; + +const mockDatasource = { + id: '2d935df8-fe7e-4fd8-bb14-35eb4ba31d45', + name: 'Superstore Datasource', + project: { id: 'cbec32db-a4a2-4308-b5f0-4fc67322f359', name: 'Samples' }, + owner: { id: 'owner-1' }, + tags: { tag: [{ label: 'tag-1' }] }, +}; + +const mockTaggedDatasource = { + ...mockDatasource, + tags: { tag: [{ label: DEFAULT_PENDING_DELETION_TAG }] }, +}; + +const validTaskId = 'a1b2c3d4-e5f6-4789-9abc-ef1234567890'; + +const mocks = vi.hoisted(() => ({ + mockGetWorkbook: vi.fn(), + mockAddTagsToWorkbook: vi.fn(), + mockDeleteWorkbook: vi.fn(), + mockQueryDatasource: vi.fn(), + mockAddTagsToDatasource: vi.fn(), + mockDeleteDatasource: vi.fn(), + mockDeleteExtractRefreshTask: vi.fn(), + mockGraphql: vi.fn(), + mockQueryUserOnSite: vi.fn(), + mockAssertAdmin: vi.fn(), + mockIsWorkbookAllowed: vi.fn(), + mockIsDatasourceAllowed: vi.fn(), + mockIsFeatureEnabled: vi.fn(), +})); + +vi.mock('../../../features/init.js', () => ({ + getFeatureGate: vi.fn(() => ({ isFeatureEnabled: mocks.mockIsFeatureEnabled })), +})); + +vi.mock('../../../restApiInstance.js', () => ({ + useRestApi: vi.fn().mockImplementation(async ({ callback }) => + callback({ + workbooksMethods: { + getWorkbook: mocks.mockGetWorkbook, + addTagsToWorkbook: mocks.mockAddTagsToWorkbook, + deleteWorkbook: mocks.mockDeleteWorkbook, + }, + datasourcesMethods: { + queryDatasource: mocks.mockQueryDatasource, + addTagsToDatasource: mocks.mockAddTagsToDatasource, + deleteDatasource: mocks.mockDeleteDatasource, + }, + tasksMethods: { + deleteExtractRefreshTask: mocks.mockDeleteExtractRefreshTask, + }, + metadataMethods: { + graphql: mocks.mockGraphql, + }, + usersMethods: { + queryUserOnSite: mocks.mockQueryUserOnSite, + }, + siteId: 'test-site-id', + userId: 'test-user-id', + }), + ), +})); + +vi.mock('../adminGate.js', () => ({ + assertAdmin: mocks.mockAssertAdmin, +})); + +vi.mock('../resourceAccessChecker.js', () => ({ + resourceAccessChecker: { + isWorkbookAllowed: mocks.mockIsWorkbookAllowed, + isDatasourceAllowed: mocks.mockIsDatasourceAllowed, + }, +})); + +vi.mock('../../../config.js', () => ({ + getConfig: vi.fn(() => ({ + adminToolsEnabled: true, + productTelemetryEnabled: false, + productTelemetryEndpoint: 'https://test.com', + server: 'https://test.tableau.com', + })), +})); + +describe('deleteContentTool', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.mockAssertAdmin.mockResolvedValue(new Ok(true)); + mocks.mockIsWorkbookAllowed.mockResolvedValue({ allowed: true }); + mocks.mockIsDatasourceAllowed.mockResolvedValue({ allowed: true }); + mocks.mockGetWorkbook.mockResolvedValue(mockWorkbook); + mocks.mockQueryDatasource.mockResolvedValue(mockDatasource); + mocks.mockQueryUserOnSite.mockResolvedValue({ + id: 'owner-1', + name: 'Owner One', + email: 'owner@example.com', + siteRole: 'SiteAdministratorCreator', + }); + mocks.mockAddTagsToWorkbook.mockResolvedValue(undefined); + mocks.mockAddTagsToDatasource.mockResolvedValue(undefined); + mocks.mockDeleteWorkbook.mockResolvedValue(undefined); + mocks.mockDeleteDatasource.mockResolvedValue(undefined); + mocks.mockDeleteExtractRefreshTask.mockResolvedValue(undefined); + mocks.mockGraphql.mockResolvedValue({ publishedDatasources: [] }); + mocks.mockIsFeatureEnabled.mockReturnValue(false); + }); + + it('exposes the consolidated tool name and paramsSchema', () => { + const tool = getDeleteContentTool(new WebMcpServer()); + expect(tool.name).toBe('delete-content'); + expect(tool.paramsSchema).toHaveProperty('resourceType'); + expect(tool.paramsSchema).toHaveProperty('resourceId'); + expect(tool.paramsSchema).toHaveProperty('confirm'); + expect(tool.paramsSchema).toHaveProperty('tag'); + expect(tool.paramsSchema).toHaveProperty('confirmationToken'); + }); + + it('is disabled when adminToolsEnabled is false', async () => { + const { getConfig } = await import('../../../config.js'); + vi.mocked(getConfig).mockReturnValueOnce({ + adminToolsEnabled: false, + } as ReturnType); + const tool = getDeleteContentTool(new WebMcpServer()); + expect(await Provider.from(tool.disabled)).toBe(true); + }); + + it('carries destructive annotations', () => { + const tool = getDeleteContentTool(new WebMcpServer()); + expect(tool.annotations).toEqual({ + title: 'Delete Tableau Content', + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }); + }); + + // --- Dispatch: workbook branch --- + + describe('resourceType=workbook', () => { + it('previews by tagging the workbook and not deleting', async () => { + const result = await getToolResult({ resourceType: 'workbook', resourceId: 'wb-1' }); + expect(result.isError).toBe(false); + invariant(result.content[0].type === 'text'); + const text = result.content[0].text; + expect(text).toContain('Preview'); + expect(text).toContain(mockWorkbook.name); + expect(text).toContain(DEFAULT_PENDING_DELETION_TAG); + expect(mocks.mockAddTagsToWorkbook).toHaveBeenCalledWith({ + workbookId: 'wb-1', + siteId: 'test-site-id', + tagLabels: [DEFAULT_PENDING_DELETION_TAG], + }); + expect(mocks.mockDeleteWorkbook).not.toHaveBeenCalled(); + }); + + it('deletes when confirm:true and the tag is present', async () => { + mocks.mockGetWorkbook.mockResolvedValue(mockTaggedWorkbook); + const result = await getToolResult({ + resourceType: 'workbook', + resourceId: 'wb-1', + confirm: true, + }); + expect(result.isError).toBe(false); + invariant(result.content[0].type === 'text'); + expect(result.content[0].text).toContain('Deleted workbook'); + expect(mocks.mockDeleteWorkbook).toHaveBeenCalledWith({ + workbookId: 'wb-1', + siteId: 'test-site-id', + }); + }); + + it('blocks confirm when the pending-deletion tag is absent', async () => { + mocks.mockGetWorkbook.mockResolvedValue(mockWorkbook); + const result = await getToolResult({ + resourceType: 'workbook', + resourceId: 'wb-1', + confirm: true, + }); + expect(result.isError).toBe(true); + expect(mocks.mockDeleteWorkbook).not.toHaveBeenCalled(); + }); + + it('honors resourceAccessChecker.isWorkbookAllowed=false', async () => { + mocks.mockIsWorkbookAllowed.mockResolvedValue({ + allowed: false, + message: 'Querying the workbook with LUID wb-1 is not allowed.', + }); + const result = await getToolResult({ resourceType: 'workbook', resourceId: 'wb-1' }); + expect(result.isError).toBe(true); + invariant(result.content[0].type === 'text'); + expect(result.content[0].text).toContain('not allowed'); + expect(mocks.mockAddTagsToWorkbook).not.toHaveBeenCalled(); + }); + + it('denies non-admin callers', async () => { + mocks.mockAssertAdmin.mockResolvedValue(new Err('User is not a site administrator')); + const result = await getToolResult({ + resourceType: 'workbook', + resourceId: 'wb-1', + confirm: true, + }); + expect(result.isError).toBe(true); + invariant(result.content[0].type === 'text'); + expect(result.content[0].text).toContain('site administrator'); + expect(mocks.mockDeleteWorkbook).not.toHaveBeenCalled(); + }); + }); + + // --- Dispatch: datasource branch --- + + describe('resourceType=datasource', () => { + it('previews by tagging the datasource and reports dependents', async () => { + const result = await getToolResult({ + resourceType: 'datasource', + resourceId: 'ds-1', + }); + expect(result.isError).toBe(false); + invariant(result.content[0].type === 'text'); + const text = result.content[0].text; + expect(text).toContain('Preview'); + expect(text).toContain(mockDatasource.name); + expect(text).toContain(DEFAULT_PENDING_DELETION_TAG); + expect(mocks.mockAddTagsToDatasource).toHaveBeenCalledWith({ + datasourceId: 'ds-1', + siteId: 'test-site-id', + tagLabels: [DEFAULT_PENDING_DELETION_TAG], + }); + expect(mocks.mockDeleteDatasource).not.toHaveBeenCalled(); + expect(mocks.mockGraphql).toHaveBeenCalled(); + }); + + it('deletes when confirm:true and the tag is present', async () => { + mocks.mockQueryDatasource.mockResolvedValue(mockTaggedDatasource); + const result = await getToolResult({ + resourceType: 'datasource', + resourceId: 'ds-1', + confirm: true, + }); + expect(result.isError).toBe(false); + invariant(result.content[0].type === 'text'); + expect(result.content[0].text).toContain('Deleted data source'); + expect(mocks.mockDeleteDatasource).toHaveBeenCalledWith({ + datasourceId: 'ds-1', + siteId: 'test-site-id', + }); + }); + + it('honors resourceAccessChecker.isDatasourceAllowed=false', async () => { + mocks.mockIsDatasourceAllowed.mockResolvedValue({ + allowed: false, + message: 'Querying the datasource with LUID ds-1 is not allowed.', + }); + const result = await getToolResult({ + resourceType: 'datasource', + resourceId: 'ds-1', + }); + expect(result.isError).toBe(true); + invariant(result.content[0].type === 'text'); + expect(result.content[0].text).toContain('not allowed'); + expect(mocks.mockAddTagsToDatasource).not.toHaveBeenCalled(); + }); + }); + + // --- Dispatch: extract-refresh-task branch --- + + describe('resourceType=extract-refresh-task', () => { + it('previews by minting a confirmation token', async () => { + const result = await getToolResult({ + resourceType: 'extract-refresh-task', + resourceId: validTaskId, + }); + expect(result.isError).toBe(false); + invariant(result.content[0].type === 'text'); + const text = result.content[0].text; + expect(text).toContain('Preview'); + expect(text).toMatch(/confirmationToken:\s*\\?"[0-9a-fA-F-]+/); + expect(mocks.mockDeleteExtractRefreshTask).not.toHaveBeenCalled(); + }); + + it('deletes when confirm:true and the confirmationToken is valid', async () => { + const previewText = await previewAndGetText({ + resourceType: 'extract-refresh-task', + resourceId: validTaskId, + }); + const match = previewText.match( + /confirmationToken:\s*\\?"([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/, + ); + invariant(match, `expected confirmationToken in preview: ${previewText}`); + const token = match[1]; + const result = await getToolResult({ + resourceType: 'extract-refresh-task', + resourceId: validTaskId, + confirm: true, + confirmationToken: token, + }); + expect(result.isError).toBe(false); + invariant(result.content[0].type === 'text'); + expect(result.content[0].text).toContain('successfully deleted'); + expect(mocks.mockDeleteExtractRefreshTask).toHaveBeenCalledWith({ + siteId: 'test-site-id', + taskId: validTaskId, + }); + }); + + it('rejects a non-UUID resourceId with a clear error', async () => { + const result = await getToolResult({ + resourceType: 'extract-refresh-task', + resourceId: 'not-a-uuid', + }); + expect(result.isError).toBe(true); + invariant(result.content[0].type === 'text'); + expect(result.content[0].text).toMatch(/uuid/i); + expect(mocks.mockDeleteExtractRefreshTask).not.toHaveBeenCalled(); + }); + + it('blocks confirm when confirmationToken is missing', async () => { + const result = await getToolResult({ + resourceType: 'extract-refresh-task', + resourceId: validTaskId, + confirm: true, + }); + expect(result.isError).toBe(true); + expect(mocks.mockDeleteExtractRefreshTask).not.toHaveBeenCalled(); + }); + }); + + // --- MCP-Apps flag ON: preview returns LEGACY namespace panel + records approval under legacy --- + + describe('with mcp-apps flag ON', () => { + beforeEach(() => { + mocks.mockIsFeatureEnabled.mockReturnValue(true); + }); + + it('carries the delete-content app config', () => { + const tool = getDeleteContentTool(new WebMcpServer()); + expect(tool.app).toBeDefined(); + expect(tool.app?.resourceUri).toContain('delete-content'); + }); + + it('workbook preview returns the LEGACY delete-workbook-confirm panel and records approval under delete-workbook', async () => { + const result = await getToolResult({ resourceType: 'workbook', resourceId: 'wb-app' }); + expect(result.isError).toBe(false); + invariant(result.content[0].type === 'text'); + const payload = JSON.parse(result.content[0].text); + // LEGACY panel kind so the existing iframe machinery keeps working end-to-end. + expect(payload.data.kind).toBe('delete-workbook-confirm'); + expect(payload.data.workbookId).toBe('wb-app'); + // Approval established under the LEGACY namespace so confirm-delete-workbook verifies OK. + const extra = getMockRequestHandlerExtra(); + await expect( + new AppApprovalEvidence().verify({ + restApi: { siteId: 'test-site-id' } as never, + siteId: 'test-site-id', + target: { id: 'wb-app', kind: 'workbook' }, + tool: 'confirm-delete-workbook', + userLuid: extra.getUserLuid(), + }), + ).resolves.toBe(true); + }); + + it('datasource preview returns the LEGACY delete-datasource-confirm panel', async () => { + const result = await getToolResult({ resourceType: 'datasource', resourceId: 'ds-app' }); + expect(result.isError).toBe(false); + invariant(result.content[0].type === 'text'); + const payload = JSON.parse(result.content[0].text); + expect(payload.data.kind).toBe('delete-datasource-confirm'); + expect(payload.data.datasourceId).toBe('ds-app'); + }); + + it('extract-refresh-task preview returns the LEGACY delete-extract-refresh-task-confirm panel', async () => { + const result = await getToolResult({ + resourceType: 'extract-refresh-task', + resourceId: validTaskId, + }); + expect(result.isError).toBe(false); + invariant(result.content[0].type === 'text'); + const payload = JSON.parse(result.content[0].text); + expect(payload.data.kind).toBe('delete-extract-refresh-task-confirm'); + expect(payload.data.taskId).toBe(validTaskId); + }); + + it('rejects model-driven confirm:true with a PreviewNotRun error (no self-confirm)', async () => { + const result = await getToolResult({ + resourceType: 'workbook', + resourceId: 'wb-1', + confirm: true, + }); + expect(result.isError).toBe(true); + invariant(result.content[0].type === 'text'); + expect(result.content[0].text).toContain('Mutation blocked'); + expect(mocks.mockDeleteWorkbook).not.toHaveBeenCalled(); + }); + }); +}); + +async function getToolResult(args: { + resourceType: 'workbook' | 'datasource' | 'extract-refresh-task'; + resourceId: string; + confirm?: boolean; + tag?: string; + confirmationToken?: string; +}): Promise { + const tool = getDeleteContentTool(new WebMcpServer()); + const callback = await Provider.from(tool.callback); + return await callback( + { + resourceType: args.resourceType, + resourceId: args.resourceId, + confirm: args.confirm, + tag: args.tag, + confirmationToken: args.confirmationToken, + }, + getMockRequestHandlerExtra(), + ); +} + +async function previewAndGetText(args: { + resourceType: 'workbook' | 'datasource' | 'extract-refresh-task'; + resourceId: string; +}): Promise { + const preview = await getToolResult(args); + invariant(!preview.isError, `preview should succeed: ${JSON.stringify(preview)}`); + invariant(preview.content[0].type === 'text'); + return preview.content[0].text; +} + +// silence unused: getAuditRecords wired up in case future tests want to assert audit rows. +void getAuditRecords; diff --git a/src/tools/web/_lib/deleteContent.ts b/src/tools/web/_lib/deleteContent.ts new file mode 100644 index 00000000..79692c64 --- /dev/null +++ b/src/tools/web/_lib/deleteContent.ts @@ -0,0 +1,629 @@ +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { Ok, Result } from 'ts-results-es'; +import { z } from 'zod'; + +import { getConfig } from '../../../config.js'; +import { + ArgsValidationError, + DatasourceNotAllowedError, + McpToolError, + PreviewNotRunError, + WorkbookNotAllowedError, +} from '../../../errors/mcpToolError.js'; +import { getFeatureGate } from '../../../features/init.js'; +import { log } from '../../../logging/logger.js'; +import { useRestApi } from '../../../restApiInstance.js'; +import { + DatasourceDownstream, + getDatasourceDownstreamByLuid, + getDatasourceDownstreamQuery, +} from '../../../sdks/tableau/methods/lineageUtils.js'; +import { RestApi } from '../../../sdks/tableau/restApi.js'; +import { WebMcpServer } from '../../../server.web.js'; +import { getExceptionMessage } from '../../../utils/getExceptionMessage.js'; +import { getAppConfig } from '../../../web/apps/appConfig.js'; +import { DeleteDatasourceConfirmPanel } from '../datasources/deleteDatasource.js'; +import { DeleteExtractRefreshTaskConfirmPanel } from '../extractRefreshTasks/deleteExtractRefreshTask.js'; +import { resourceAccessChecker } from '../resourceAccessChecker.js'; +import { AppToolResult, WebTool } from '../tool.js'; +import { TableauWebRequestHandlerExtra } from '../toolContext.js'; +import { resolveOwnerEmail } from '../users/resolveOwnerEmail.js'; +import { DeleteWorkbookConfirmPanel } from '../workbooks/deleteWorkbook.js'; +import { + AppApprovalEvidence, + DEFAULT_PENDING_DELETION_TAG, + getMutationPreviewTtlMs, + RegistryEvidence, + TagEvidence, +} from './evidence.js'; +import { guardMutation, MutationTarget } from './mutationGuard.js'; + +/** + * Consolidated destructive-delete tool (W-23375797). Dispatches on `resourceType`: + * + * - `workbook` — mirrors delete-workbook (TagEvidence, resourceAccessChecker.isWorkbookAllowed). + * - `datasource` — mirrors delete-datasource (TagEvidence, isDatasourceAllowed, downstream warning). + * - `extract-refresh-task` — mirrors delete-extract-refresh-task (RegistryEvidence nonce). + * + * Two-phase (preview → confirm), same guarantees as the underlying tools: + * - Flag OFF (`mcp-apps`): preview tags/mints-nonce; confirm re-verifies evidence and deletes. + * - Flag ON: model-driven `confirm:true` is CLOSED; preview also records `AppApprovalEvidence` + * under the LEGACY per-resource namespace and returns the LEGACY confirm-panel `data.kind` + * (`delete-workbook-confirm` / `delete-datasource-confirm` / `delete-extract-refresh-task-confirm`). + * That keeps the existing iframe machinery working end-to-end — the panel's Confirm button + * invokes the legacy `confirm-delete-{resource}` tool, whose AppApprovalEvidence check under + * the same legacy namespace passes. + * + * This tool is registered alongside the legacy `delete-{workbook,datasource,extract-refresh-task}` + * tools for one release cycle; a follow-up PR drops the shims once callers have migrated. + */ + +const RECYCLE_BIN_DOC_URL = 'https://help.tableau.com/current/pro/desktop/en-us/recycle_bin.htm'; + +const resourceTypeSchema = z.enum(['workbook', 'datasource', 'extract-refresh-task']); +type ResourceType = z.infer; + +const paramsSchema = { + resourceType: resourceTypeSchema.describe( + 'The kind of resource to delete: "workbook", "datasource", or "extract-refresh-task".', + ), + resourceId: z + .string() + .describe( + 'The LUID of the workbook or data source, or the UUID of the extract refresh task. ' + + 'For extract-refresh-task, must be a valid UUID.', + ), + confirm: z + .boolean() + .optional() + .describe( + 'When omitted or false, runs a non-destructive preview. When true, permanently deletes — ' + + 'but only if the prior-preview evidence is present (tag for workbook/datasource, ' + + 'confirmationToken for extract-refresh-task). ' + + 'When the `mcp-apps` feature is enabled, model-driven confirm is CLOSED: the destructive ' + + 'step requires a human gesture in the in-iframe confirm panel.', + ), + tag: z + .string() + .max(200) + .regex( + /^[A-Za-z0-9 _-]+$/, + 'tag must contain only letters, numbers, spaces, underscores, and dashes', + ) + .optional() + .describe( + 'Only for resourceType="workbook" or "datasource": the pending-deletion tag label. ' + + `Defaults to '${DEFAULT_PENDING_DELETION_TAG}'.`, + ), + confirmationToken: z + .string() + .optional() + .describe( + 'Only for resourceType="extract-refresh-task": the single-use token returned by a prior ' + + 'preview call. Required when confirm is true; ignored otherwise.', + ), +}; + +type DeleteContentResult = + | string + | AppToolResult + | AppToolResult + | AppToolResult; + +export const getDeleteContentTool = (server: WebMcpServer): WebTool => { + const config = getConfig(); + const mcpAppsEnabled = getFeatureGate().isFeatureEnabled('mcp-apps'); + + const tool = new WebTool({ + server, + name: 'delete-content', + disabled: !config.adminToolsEnabled, + ...(mcpAppsEnabled ? { app: getAppConfig('delete-content', 'hitl-confirm') } : {}), + description: ` +Permanently deletes a Tableau resource — a **workbook**, **published data source**, or **extract +refresh task** — from the current site. Restricted to Tableau site administrators and requires the +\`ADMIN_TOOLS_ENABLED\` feature flag. + +This is the consolidated form of \`delete-workbook\`, \`delete-datasource\`, and +\`delete-extract-refresh-task\`. Dispatches on \`resourceType\`: + +- **\`workbook\`** — pass \`resourceId\` = workbook LUID. Optional \`tag\` (defaults to + \`${DEFAULT_PENDING_DELETION_TAG}\`). +- **\`datasource\`** — pass \`resourceId\` = data source LUID. Optional \`tag\`. Preview additionally + reports dependent workbooks and flows. +- **\`extract-refresh-task\`** — pass \`resourceId\` = task UUID. On confirm, requires + \`confirmationToken\` from the prior preview call. + +Two-phase (preview → confirm), same as the underlying tools: + +1. **Preview (default — \`confirm\` omitted or false):** tags the workbook/data source as pending + deletion (reversible) or mints a single-use \`confirmationToken\` for extract refresh tasks. + Reports what would be deleted. Does NOT delete anything. +2. **Delete (\`confirm: true\`):** permanently deletes. Server verifies the pending-deletion tag + (workbook/datasource) or consumes the confirmationToken (task) — a confirm without a valid + prior preview is rejected. + +**Required human confirmation:** After preview, present the resource details to the user and get +explicit approval before calling again with \`confirm: true\`. Do not auto-confirm. + +Workbooks and data sources on Tableau Cloud go to the recycle bin and can be restored for a +limited time (${RECYCLE_BIN_DOC_URL}); on Tableau Server datasource deletion is permanent. Extract +refresh task deletion is always permanent. +`.trim(), + paramsSchema, + annotations: { + title: 'Delete Tableau Content', + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, + callback: async ( + { resourceType, resourceId, confirm, tag, confirmationToken }, + extra, + ): Promise => { + const configWithOverrides = await extra.getConfigWithOverrides(); + + return await tool.logAndExecute({ + extra, + args: { resourceType, resourceId, confirm, tag, confirmationToken }, + callback: async () => { + // Reject bad resourceId shape before opening a Tableau REST session. Only + // extract-refresh-task requires a UUID; workbook/datasource accept any LUID string and + // let the REST call surface a 404 if it's genuinely unknown. + if (resourceType === 'extract-refresh-task') { + const uuidCheck = z + .string() + .uuid('resourceId must be a valid UUID for extract-refresh-task') + .safeParse(resourceId); + if (!uuidCheck.success) { + return new ArgsValidationError(uuidCheck.error.issues[0].message).toErr(); + } + } + return await useRestApi({ + ...extra, + jwtScopes: tool.requiredApiScopes, + callback: async (restApi) => { + // Flag ON (MCP-Apps HITL): the model-driven confirm:true path is CLOSED so an agent + // cannot self-confirm a deletion by re-calling this tool — the only route to deletion + // is a human gesture in the confirm panel (which fires the LEGACY confirm-delete-*). + // Flag OFF keeps the original evidence-gated confirm:true path intact. + if (confirm && mcpAppsEnabled) { + return new PreviewNotRunError( + `Mutation blocked: deleting a ${humanResource(resourceType)} requires a human ` + + 'confirmation in the approval panel. Run delete-content in preview (omit ' + + 'confirm) to open the panel; the deletion is performed only when a person ' + + "clicks Delete. The assistant cannot confirm on the user's behalf.", + ).toErr(); + } + + switch (resourceType) { + case 'workbook': + return await runWorkbookBranch({ + restApi, + extra, + workbookId: resourceId, + confirm, + tag, + mcpAppsEnabled, + }); + case 'datasource': + return await runDatasourceBranch({ + restApi, + extra, + datasourceId: resourceId, + confirm, + tag, + mcpAppsEnabled, + disableMetadataApiRequests: configWithOverrides.disableMetadataApiRequests, + }); + case 'extract-refresh-task': + return await runExtractRefreshTaskBranch({ + restApi, + extra, + taskId: resourceId, + confirm, + confirmationToken, + mcpAppsEnabled, + }); + } + }, + }); + }, + constrainSuccessResult: (result) => ({ type: 'success', result }), + }); + }, + }); + + return tool; +}; + +function humanResource(kind: ResourceType): string { + return kind === 'extract-refresh-task' + ? 'extract refresh task' + : kind === 'datasource' + ? 'data source' + : 'workbook'; +} + +async function runWorkbookBranch({ + restApi, + extra, + workbookId, + confirm, + tag, + mcpAppsEnabled, +}: { + restApi: RestApi; + extra: TableauWebRequestHandlerExtra; + workbookId: string; + confirm: boolean | undefined; + tag: string | undefined; + mcpAppsEnabled: boolean; +}): Promise> { + const siteId = restApi.siteId; + const pendingTag = tag?.trim() || DEFAULT_PENDING_DELETION_TAG; + + const isWorkbookAllowedResult = await resourceAccessChecker.isWorkbookAllowed({ + workbookId, + extra, + }); + if (!isWorkbookAllowedResult.allowed) { + return new WorkbookNotAllowedError(isWorkbookAllowedResult.message).toErr(); + } + + const resolveTarget = async (): Promise => { + const workbook = + isWorkbookAllowedResult.content ?? + (await restApi.workbooksMethods.getWorkbook({ workbookId, siteId })); + const ownerEmail = await resolveOwnerEmail( + restApi, + siteId, + workbook.owner?.id, + 'delete-content', + ); + return { + id: workbookId, + name: workbook.name, + project: workbook.project?.name, + owner: ownerEmail ?? undefined, + kind: 'workbook', + }; + }; + + const guardResult = await guardMutation({ + restApi, + extra, + tool: 'delete-content', + action: 'delete', + mode: 'preview-confirm', + phase: confirm ? 'confirm' : 'preview', + evidence: new TagEvidence({ tag: pendingTag, kind: 'workbook' }), + resolveTarget, + fallbackTargetKind: 'workbook', + }); + if (guardResult.isErr()) { + return guardResult.error.toErr(); + } + const { target, recordOutcome } = guardResult.value; + const projectName = target.project ?? 'unknown project'; + const ownerText = target.owner ? `owner ${target.owner}` : 'owner unknown'; + + if (confirm) { + try { + await restApi.workbooksMethods.deleteWorkbook({ workbookId, siteId }); + } catch (e) { + recordOutcome({ ok: false, failureDetail: getExceptionMessage(e) }); + throw e; + } + recordOutcome({ ok: true }); + return new Ok( + `Deleted workbook '${target.name}' (id ${workbookId}) in '${projectName}', ${ownerText}. ` + + `It can be restored from the Tableau recycle bin (${RECYCLE_BIN_DOC_URL}) for a limited ` + + 'time before permanent removal.', + ); + } + + if (mcpAppsEnabled) { + // Establish approval under the LEGACY 'delete-workbook' namespace so the iframe's Confirm click + // (which fires the legacy confirm-delete-workbook tool) sees this approval as valid. + await new AppApprovalEvidence('delete-workbook').establish({ + restApi, + siteId, + target, + tool: 'confirm-delete-workbook', + userLuid: extra.getUserLuid(), + }); + const expiresAtMs = Date.now() + getMutationPreviewTtlMs(); + return new Ok({ + data: { + kind: 'delete-workbook-confirm', + workbookId, + name: target.name, + project: target.project, + owner: target.owner, + expiresAtMs, + }, + url: '', + }); + } + + return new Ok( + `Preview — workbook '${target.name}' (id ${workbookId}) in '${projectName}', ${ownerText}. ` + + `It has been tagged '${pendingTag}' (reversible). ` + + 'NEXT STEP — REQUIRED: show this workbook (name, project, owner) to the user and ask them ' + + 'to explicitly confirm deleting it. Do NOT delete without the user’s approval. ' + + 'Once approved, call again with confirm: true (the server will verify this ' + + `'${pendingTag}' tag before deleting). ` + + `Deleted workbooks are recoverable from the Tableau recycle bin (${RECYCLE_BIN_DOC_URL}) ` + + 'for a limited time.', + ); +} + +async function runDatasourceBranch({ + restApi, + extra, + datasourceId, + confirm, + tag, + mcpAppsEnabled, + disableMetadataApiRequests, +}: { + restApi: RestApi; + extra: TableauWebRequestHandlerExtra; + datasourceId: string; + confirm: boolean | undefined; + tag: string | undefined; + mcpAppsEnabled: boolean; + disableMetadataApiRequests: boolean; +}): Promise> { + const siteId = restApi.siteId; + const pendingTag = tag?.trim() || DEFAULT_PENDING_DELETION_TAG; + + const isDatasourceAllowedResult = await resourceAccessChecker.isDatasourceAllowed({ + datasourceLuid: datasourceId, + extra, + }); + if (!isDatasourceAllowedResult.allowed) { + return new DatasourceNotAllowedError(isDatasourceAllowedResult.message).toErr(); + } + + const resolveTarget = async (): Promise => { + const datasource = + isDatasourceAllowedResult.content ?? + (await restApi.datasourcesMethods.queryDatasource({ datasourceId, siteId })); + const ownerEmail = await resolveOwnerEmail( + restApi, + siteId, + datasource.owner?.id, + 'delete-content', + ); + return { + id: datasourceId, + name: datasource.name, + project: datasource.project?.name, + owner: ownerEmail ?? undefined, + kind: 'datasource', + }; + }; + + const guardResult = await guardMutation({ + restApi, + extra, + tool: 'delete-content', + action: 'delete', + mode: 'preview-confirm', + phase: confirm ? 'confirm' : 'preview', + evidence: new TagEvidence({ tag: pendingTag, kind: 'datasource' }), + resolveTarget, + fallbackTargetKind: 'datasource', + }); + if (guardResult.isErr()) { + return guardResult.error.toErr(); + } + const { target, recordOutcome } = guardResult.value; + const projectName = target.project ?? 'unknown project'; + const ownerText = target.owner ? `owner ${target.owner}` : 'owner unknown'; + + if (confirm) { + try { + await restApi.datasourcesMethods.deleteDatasource({ datasourceId, siteId }); + } catch (e) { + recordOutcome({ ok: false, failureDetail: getExceptionMessage(e) }); + throw e; + } + recordOutcome({ ok: true }); + return new Ok( + `Deleted data source '${target.name}' (id ${datasourceId}) in '${projectName}', ${ownerText}. ` + + `On Tableau Cloud it can be restored from the recycle bin (${RECYCLE_BIN_DOC_URL}) for a ` + + 'limited time before permanent removal; on Tableau Server deletion is permanent. ' + + 'Dependent workbooks and flows were not deleted but no longer have this data source.', + ); + } + + if (mcpAppsEnabled) { + // Legacy 'delete-datasource' namespace (see workbook branch for rationale). + await new AppApprovalEvidence('delete-datasource').establish({ + restApi, + siteId, + target, + tool: 'confirm-delete-datasource', + userLuid: extra.getUserLuid(), + }); + const expiresAtMs = Date.now() + getMutationPreviewTtlMs(); + return new Ok({ + data: { + kind: 'delete-datasource-confirm', + datasourceId, + name: target.name, + project: target.project, + owner: target.owner, + expiresAtMs, + }, + url: '', + }); + } + + const dependencyWarning = await describeDownstreamDependencies({ + restApi, + datasourceId, + disableMetadataApiRequests, + }); + + return new Ok( + `Preview — data source '${target.name}' (id ${datasourceId}) in '${projectName}', ${ownerText}. ` + + `${dependencyWarning} ` + + `It has been tagged '${pendingTag}' (reversible). ` + + 'NEXT STEP — REQUIRED: show this data source (name, project, owner) and its dependent ' + + 'content to the user and ask them to explicitly confirm deleting it. Do NOT delete ' + + 'without the user’s approval. ' + + 'Once approved, call again with confirm: true (the server will verify this ' + + `'${pendingTag}' tag before deleting). ` + + 'On Tableau Cloud deleted data sources are recoverable from the recycle bin ' + + `(${RECYCLE_BIN_DOC_URL}) for a limited time; on Tableau Server deletion is permanent.`, + ); +} + +async function runExtractRefreshTaskBranch({ + restApi, + extra, + taskId, + confirm, + confirmationToken, + mcpAppsEnabled, +}: { + restApi: RestApi; + extra: TableauWebRequestHandlerExtra; + taskId: string; + confirm: boolean | undefined; + confirmationToken: string | undefined; + mcpAppsEnabled: boolean; +}): Promise> { + const siteId = restApi.siteId; + + const resolveTarget = async (): Promise => ({ + id: taskId, + kind: 'extract-refresh-task', + }); + + const evidence = new RegistryEvidence(); + + const guardResult = await guardMutation({ + restApi, + extra, + tool: 'delete-content', + action: 'delete', + mode: 'preview-confirm', + phase: confirm ? 'confirm' : 'preview', + evidence, + resolveTarget, + confirmationToken, + fallbackTargetKind: 'extract-refresh-task', + }); + if (guardResult.isErr()) { + return guardResult.error.toErr(); + } + const { recordOutcome } = guardResult.value; + + if (confirm) { + try { + await restApi.tasksMethods.deleteExtractRefreshTask({ siteId, taskId }); + } catch (e) { + recordOutcome({ ok: false, failureDetail: getExceptionMessage(e) }); + throw e; + } + recordOutcome({ ok: true }); + return new Ok( + `Extract refresh task '${taskId}' has been successfully deleted. The underlying data source ` + + 'or workbook is unaffected, but it will no longer be refreshed on this schedule.', + ); + } + + if (mcpAppsEnabled) { + // Legacy 'delete-extract-refresh-task' namespace (see workbook branch for rationale). + await new AppApprovalEvidence('delete-extract-refresh-task').establish({ + restApi, + siteId, + target: { id: taskId, kind: 'extract-refresh-task' }, + tool: 'confirm-delete-extract-refresh-task', + userLuid: extra.getUserLuid(), + }); + const expiresAtMs = Date.now() + getMutationPreviewTtlMs(); + return new Ok({ + data: { + kind: 'delete-extract-refresh-task-confirm', + taskId, + expiresAtMs, + }, + url: '', + }); + } + + const nonce = evidence.getEstablishedNonce(); + return new Ok( + `Preview — extract refresh task '${taskId}' would be permanently deleted (the underlying data ` + + 'source or workbook is unaffected, but it will no longer be refreshed on this schedule). ' + + 'NEXT STEP — REQUIRED: present this task to the user and ask them to explicitly confirm ' + + 'deleting it. Do NOT delete without the user’s approval. ' + + `Once approved, call again with confirm: true and confirmationToken: "${nonce}" ` + + '(the server will verify and consume this single-use token before deleting).', + ); +} + +/** + * Duplicates the downstream-dependency warning logic from deleteDatasource.ts. Best-effort; if the + * Metadata API is disabled or errors, degrades to a neutral note and never fails the preview. + */ +async function describeDownstreamDependencies({ + restApi, + datasourceId, + disableMetadataApiRequests, +}: { + restApi: RestApi; + datasourceId: string; + disableMetadataApiRequests: boolean; +}): Promise { + if (disableMetadataApiRequests) { + return 'Dependency check skipped (Metadata API requests are disabled).'; + } + + let downstream: DatasourceDownstream | undefined; + try { + const response = await restApi.metadataMethods.graphql( + getDatasourceDownstreamQuery([datasourceId]), + ); + downstream = getDatasourceDownstreamByLuid(response).get(datasourceId); + } catch (error) { + log({ + message: `delete-content(datasource): downstream dependency check failed for ${datasourceId}`, + level: 'warning', + logger: 'delete-content', + data: getExceptionMessage(error), + }); + return 'Dependency check unavailable (Metadata API error) — verify dependents manually before deleting.'; + } + + const workbooks = downstream?.workbooks ?? []; + const flows = downstream?.flows ?? []; + if (workbooks.length === 0 && flows.length === 0) { + return 'No workbooks or flows were found that depend on this data source.'; + } + + const parts: string[] = []; + if (workbooks.length > 0) { + parts.push(`${workbooks.length} workbook(s): ${formatDependentNames(workbooks)}`); + } + if (flows.length > 0) { + parts.push(`${flows.length} flow(s): ${formatDependentNames(flows)}`); + } + return `⚠️ WARNING: deleting this data source may break ${parts.join(' and ')}.`; +} + +const MAX_DEPENDENT_NAMES_LISTED = 10; + +function formatDependentNames(contents: ReadonlyArray<{ name: string }>): string { + const names = contents.slice(0, MAX_DEPENDENT_NAMES_LISTED).map((c) => c.name); + const remaining = contents.length - names.length; + const listed = names.join(', '); + return remaining > 0 ? `${listed}, …and ${remaining} more` : listed; +} diff --git a/src/tools/web/_lib/mutationGuard.ts b/src/tools/web/_lib/mutationGuard.ts index e885ef21..23ef22cf 100644 --- a/src/tools/web/_lib/mutationGuard.ts +++ b/src/tools/web/_lib/mutationGuard.ts @@ -91,6 +91,7 @@ export async function guardMutation({ confirmationToken, previewTool, binding, + fallbackTargetKind, }: { restApi: RestApi; extra: TableauWebRequestHandlerExtra; @@ -111,6 +112,10 @@ export async function guardMutation({ // Optional fingerprint of the caller-controlled parameters (see EvidenceContext.binding). Bound // into the evidence so a confirm applies exactly what was previewed, never a swapped-in payload. binding?: string; + // Optional override for the `target.kind` used in the DENIED audit fallback when `resolveTarget()` + // itself fails. Required for polymorphic tools like `delete-content` where the caller knows the + // dispatched resource kind but `targetKindHint(tool)` cannot derive it from the tool name alone. + fallbackTargetKind?: MutationTarget['kind']; }): Promise> { // (2) Build the actor identity up front so a denied attempt is still attributable in the audit. const actor: MutationActor = { @@ -132,7 +137,7 @@ export async function guardMutation({ try { deniedTarget = await resolveTarget(); } catch { - deniedTarget = { id: 'unresolved', kind: targetKindHint(tool) }; + deniedTarget = { id: 'unresolved', kind: fallbackTargetKind ?? targetKindHint(tool) }; } emitAuditRecord({ actor, diff --git a/src/tools/web/adminInsights/getStaleContentReport.ts b/src/tools/web/adminInsights/getStaleContentReport.ts index 0bd294dd..a1de09b6 100644 --- a/src/tools/web/adminInsights/getStaleContentReport.ts +++ b/src/tools/web/adminInsights/getStaleContentReport.ts @@ -572,3 +572,13 @@ export const exportedForTesting = { buildProjectIdWarnings, parseSize, }; + +// Exported for reuse by query-admin-insights (kind=stale-content). These are the same helpers the +// stale-content report uses; sharing them avoids a second implementation drifting out of sync while +// the legacy get-stale-content-report shim remains registered. +export { + buildSiteContentQuery as _buildSiteContentQuery, + resolveProjectIdsToNames as _resolveProjectIdsToNames, + resolveProjectScopeIds as _resolveProjectScopeIds, + siteContentRowSchema as _siteContentRowSchema, +}; diff --git a/src/tools/web/adminInsights/queryAdminInsights.test.ts b/src/tools/web/adminInsights/queryAdminInsights.test.ts new file mode 100644 index 00000000..6cf70bb6 --- /dev/null +++ b/src/tools/web/adminInsights/queryAdminInsights.test.ts @@ -0,0 +1,202 @@ +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { Err, Ok } from 'ts-results-es'; + +import { Query } from '../../../sdks/tableau/apis/vizqlDataServiceApi.js'; +import { WebMcpServer } from '../../../server.web.js'; +import { Provider } from '../../../utils/provider.js'; +import { getMockRequestHandlerExtra } from '../toolContext.mock.js'; +import { clearStaleContentReportCache } from './getStaleContentReport.js'; +import { getQueryAdminInsightsTool } from './queryAdminInsights.js'; +import { adminInsightsResolver } from './resolver.js'; + +const mocks = vi.hoisted(() => ({ + mockQueryDatasource: vi.fn(), + mockListDatasources: vi.fn(), + mockAssertAdmin: vi.fn(), + mockQueryProjects: vi.fn(), +})); + +vi.mock('../../../restApiInstance.js', () => ({ + useRestApi: vi.fn().mockImplementation(async ({ callback }) => + callback({ + siteId: 'site-test', + userId: 'user-test', + vizqlDataServiceMethods: { + queryDatasource: mocks.mockQueryDatasource, + }, + datasourcesMethods: { + listDatasources: mocks.mockListDatasources, + }, + projectsMethods: { + queryProjects: mocks.mockQueryProjects, + }, + }), + ), +})); + +vi.mock('../adminGate.js', () => ({ + assertAdmin: mocks.mockAssertAdmin, +})); + +const validQuery: Query = { + fields: [{ fieldCaption: 'Item ID' }], +}; + +describe('query-admin-insights tool', () => { + beforeEach(() => { + vi.clearAllMocks(); + adminInsightsResolver.clearCache(); + clearStaleContentReportCache(); + + mocks.mockAssertAdmin.mockResolvedValue(new Ok(true)); + // Resolver filters by projectName:eq:Admin Insights (single call) and matches datasets by name + // client-side, so the mock returns every dataset the resolver might look up in one page. + mocks.mockListDatasources.mockResolvedValue({ + pagination: { pageNumber: 1, pageSize: 100, totalAvailable: 3 }, + datasources: [ + { id: 'luid-tse', name: 'TS Events' }, + { id: 'luid-sc', name: 'Site Content' }, + { id: 'luid-jp', name: 'Job Performance' }, + ], + }); + mocks.mockQueryProjects.mockResolvedValue({ + pagination: { pageNumber: 1, pageSize: 1000, totalAvailable: 0 }, + projects: [], + }); + }); + + it('exposes the documented tool name', () => { + const tool = getQueryAdminInsightsTool(new WebMcpServer()); + expect(tool.name).toBe('query-admin-insights'); + }); + + it('dispatches kind=ts-events to the TS Events datasource', async () => { + mocks.mockQueryDatasource.mockResolvedValue(new Ok({ data: [{ 'Item ID': 'wb-1' }] })); + + const result = await getToolResult({ kind: 'ts-events', query: validQuery }); + + expect(result.isError).toBeFalsy(); + expect(mocks.mockQueryDatasource).toHaveBeenCalledWith( + expect.objectContaining({ datasource: { datasourceLuid: 'luid-tse' } }), + ); + }); + + it('dispatches kind=site-content to the Site Content datasource', async () => { + mocks.mockQueryDatasource.mockResolvedValue(new Ok({ data: [] })); + + const result = await getToolResult({ kind: 'site-content', query: validQuery }); + + expect(result.isError).toBeFalsy(); + expect(mocks.mockQueryDatasource).toHaveBeenCalledWith( + expect.objectContaining({ datasource: { datasourceLuid: 'luid-sc' } }), + ); + }); + + it('dispatches kind=job-performance to the Job Performance datasource', async () => { + mocks.mockQueryDatasource.mockResolvedValue(new Ok({ data: [] })); + + const result = await getToolResult({ kind: 'job-performance', query: validQuery }); + + expect(result.isError).toBeFalsy(); + expect(mocks.mockQueryDatasource).toHaveBeenCalledWith( + expect.objectContaining({ datasource: { datasourceLuid: 'luid-jp' } }), + ); + }); + + it('returns an error when a raw-VDS kind is called without a query', async () => { + const result = await getToolResult({ kind: 'ts-events' }); + + expect(result.isError).toBe(true); + if (result.content[0].type !== 'text') { + throw new Error('expected text content'); + } + expect(result.content[0].text).toContain('query is required'); + }); + + it('dispatches kind=stale-content to Site Content with the stale-content query shape', async () => { + const today = new Date(); + const veryOld = new Date(today.getTime() - 400 * 24 * 60 * 60 * 1000).toISOString(); + mocks.mockQueryDatasource.mockResolvedValue( + new Ok({ + data: [ + { + 'Item ID': 42, + 'Item LUID': 'wb-luid-1', + 'Item Type': 'Workbook', + 'Item Name': 'Stale WB', + 'Item Parent Project Name': 'Sales', + 'Owner Email': 'owner@example.com', + 'Created At': veryOld, + 'Updated At': veryOld, + 'Last Accessed At': veryOld, + 'Size (bytes)': 12345, + }, + ], + }), + ); + + const result = await getToolResult({ kind: 'stale-content' }); + + expect(result.isError).toBeFalsy(); + expect(mocks.mockQueryDatasource).toHaveBeenCalledWith( + expect.objectContaining({ datasource: { datasourceLuid: 'luid-sc' } }), + ); + if (result.content[0].type !== 'text') { + throw new Error('expected text content'); + } + const payload = JSON.parse(result.content[0].text); + expect(payload.totalStaleItems).toBe(1); + expect(payload.rows[0].itemLuid).toBe('wb-luid-1'); + }); + + it('returns 403 when the caller is not an admin (raw VDS kind)', async () => { + mocks.mockAssertAdmin.mockResolvedValueOnce( + new Err('This tool requires site administrator permissions. Your site role is: Viewer'), + ); + + const result = await getToolResult({ kind: 'ts-events', query: validQuery }); + + expect(result.isError).toBe(true); + if (result.content[0].type !== 'text') { + throw new Error('expected text content'); + } + expect(result.content[0].text).toContain('admin'); + }); + + it('returns 403 when the caller is not an admin (stale-content kind)', async () => { + mocks.mockAssertAdmin.mockResolvedValueOnce( + new Err('This tool requires site administrator permissions. Your site role is: Viewer'), + ); + + const result = await getToolResult({ kind: 'stale-content' }); + + expect(result.isError).toBe(true); + if (result.content[0].type !== 'text') { + throw new Error('expected text content'); + } + expect(result.content[0].text).toContain('admin'); + }); +}); + +async function getToolResult(params: { + kind: 'ts-events' | 'site-content' | 'job-performance' | 'stale-content'; + query?: Query; + limit?: number; + minAgeDays?: number; + projectIds?: string[]; + itemTypes?: Array<'Workbook' | 'Datasource'>; +}): Promise { + const tool = getQueryAdminInsightsTool(new WebMcpServer()); + const callback = await Provider.from(tool.callback); + return await callback( + { + kind: params.kind, + query: params.query, + limit: params.limit, + minAgeDays: params.minAgeDays, + projectIds: params.projectIds, + itemTypes: params.itemTypes, + }, + getMockRequestHandlerExtra(), + ); +} diff --git a/src/tools/web/adminInsights/queryAdminInsights.ts b/src/tools/web/adminInsights/queryAdminInsights.ts new file mode 100644 index 00000000..1ff93249 --- /dev/null +++ b/src/tools/web/adminInsights/queryAdminInsights.ts @@ -0,0 +1,275 @@ +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { Ok } from 'ts-results-es'; +import { z } from 'zod'; + +import { getConfig } from '../../../config.js'; +import { AdminOnlyError } from '../../../errors/mcpToolError.js'; +import { useRestApi } from '../../../restApiInstance.js'; +import { querySchema } from '../../../sdks/tableau/apis/vizqlDataServiceApi.js'; +import { WebMcpServer } from '../../../server.web.js'; +import { assertAdmin } from '../adminGate.js'; +import { WebTool } from '../tool.js'; +import { executeAdminInsightsQuery, runAdminInsightsQuery } from './adminInsightsToolBase.js'; +import { + _buildSiteContentQuery, + _resolveProjectIdsToNames, + _resolveProjectScopeIds, + _siteContentRowSchema, + computeStaleRows, + StaleContentRow, +} from './getStaleContentReport.js'; +import { ADMIN_INSIGHTS_DATASETS, AdminInsightsDataset } from './resolver.js'; + +/** + * Consolidated admin-insights tool (W-23375797). Dispatches on `kind` to one of four backends: + * + * - `ts-events` — raw VDS query against the "TS Events" datasource + * - `site-content` — raw VDS query against the "Site Content" datasource + * - `job-performance` — raw VDS query against the "Job Performance" datasource + * - `stale-content` — server-side anti-join that returns already-filtered stale rows + * + * This is a superset of the four legacy admin-insights tools it replaces + * (`query-admin-insights-ts-events`, `query-admin-insights-site-content`, + * `query-admin-insights-job-performance`, `get-stale-content-report`), which remain registered as + * additive back-compat shims for one release cycle and share the underlying implementation. + */ + +const kindSchema = z.enum(['ts-events', 'site-content', 'job-performance', 'stale-content']); +type Kind = z.infer; + +const paramsSchema = { + kind: kindSchema.describe( + 'Which admin-insights backend to query. Use "ts-events", "site-content", or "job-performance" ' + + 'for raw VDS queries; use "stale-content" for the deterministic stale-content anti-join.', + ), + query: querySchema + .optional() + .describe( + 'VDS query object (fields, filters, parameters). REQUIRED when kind is "ts-events", ' + + '"site-content", or "job-performance"; IGNORED when kind is "stale-content".', + ), + limit: z + .number() + .int() + .min(1) + .optional() + .describe( + 'Optional row limit. Applied when kind is "ts-events", "site-content", or ' + + '"job-performance"; IGNORED when kind is "stale-content".', + ), + minAgeDays: z + .number() + .int() + .min(1) + .max(3650) + .optional() + .describe( + 'For kind="stale-content" only: minimum days since last access for content to be considered ' + + 'stale. Defaults to 90 days.', + ), + projectIds: z + .array(z.string()) + .optional() + .describe( + 'For kind="stale-content" only: optional list of project LUIDs to scope the report to. ' + + 'If omitted, returns all projects the caller can access.', + ), + itemTypes: z + .array(z.enum(['Workbook', 'Datasource'])) + .optional() + .describe( + 'For kind="stale-content" only: optional filter for item types. ' + + 'Defaults to ["Workbook", "Datasource"].', + ), +}; + +type StaleContentResult = { + thresholdDays: number; + totalStaleItems: number; + totalStaleSizeBytes: number; + rows: StaleContentRow[]; +}; + +export const getQueryAdminInsightsTool = (server: WebMcpServer): WebTool => { + const config = getConfig(); + const tool = new WebTool({ + server, + name: 'query-admin-insights', + disabled: !config.adminToolsEnabled, + description: ` +Queries the Tableau Admin Insights datasources on the current site. Restricted to Tableau site +administrators on Tableau Cloud sites with Admin Insights enabled. + +Dispatches on the required \`kind\` parameter: + +- **\`ts-events\`** — Raw VDS query against the "TS Events" published datasource (access events, + publishes, sign-ins). Pass a fully-formed VDS \`query\`. +- **\`site-content\`** — Raw VDS query against the "Site Content" datasource (workbooks, + datasources, projects and their metadata). Pass a fully-formed VDS \`query\`. +- **\`job-performance\`** — Raw VDS query against the "Job Performance" datasource (extract + refresh jobs, subscription jobs, flow runs, bridge jobs). Pass a fully-formed VDS \`query\`. +- **\`stale-content\`** — Server-side anti-join that returns already-filtered stale content + rows. Pass optional \`minAgeDays\`, \`projectIds\`, \`itemTypes\`. Do NOT pass \`query\` or \`limit\`. + +**Parameter reference by \`kind\`:** +- \`ts-events\` | \`site-content\` | \`job-performance\`: \`query\` (required), \`limit\` (optional). +- \`stale-content\`: \`minAgeDays\`, \`projectIds\`, \`itemTypes\` (all optional). + +**Stale-content output schema (JSON):** +\`\`\`json +{ + "thresholdDays": 90, + "totalStaleItems": , + "totalStaleSizeBytes": , + "rows": [ + { + "itemId": "...", + "itemLuid": "..." | null, + "itemType": "Workbook" | "Datasource", + "itemName": "...", + "project": "..." | null, + "ownerEmail": "..." | null, + "createdAt": "ISO date" | null, + "updatedAt": "ISO date" | null, + "lastUsedDate": "ISO date", + "daysSinceLastUse": , + "size": | null, + "neverAccessed": + } + ] +} +\`\`\` + +Notes: +- The Tableau-managed "Admin Insights" project is excluded from stale-content by design. +- \`Last Accessed At\` is \`null\` for items that have never been accessed; the report ages those + items from \`Created At\` instead. +- The underlying datasource LUIDs are resolved automatically; callers do not pass \`datasourceLuid\`. +- This tool bypasses the standard datasource access checker because Admin Insights datasources + are internal and admin-gated. +`.trim(), + paramsSchema, + annotations: { + title: 'Query Admin Insights', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + callback: async ( + { kind, query, limit, minAgeDays, projectIds, itemTypes }, + extra, + ): Promise => { + const configWithOverrides = await extra.getConfigWithOverrides(); + + if (kind === 'stale-content') { + const thresholdDays = minAgeDays ?? configWithOverrides.staleContentMinAgeDays; + const types = itemTypes ?? ['Workbook', 'Datasource']; + const requestedProjectIds = _resolveProjectScopeIds({ + argProjectIds: projectIds, + boundedProjectIds: configWithOverrides.boundedContext.projectIds, + }); + + return await tool.logAndExecute({ + extra, + args: { kind, minAgeDays: thresholdDays, projectIds, itemTypes: types }, + callback: async () => { + return await useRestApi({ + ...extra, + jwtScopes: tool.requiredApiScopes, + callback: async (restApi) => { + const adminResult = await assertAdmin(restApi, extra); + if (adminResult.isErr()) { + return new AdminOnlyError(adminResult.error).toErr(); + } + + let projectNameScope: ReadonlyArray | null = null; + if (requestedProjectIds) { + const namesResult = await _resolveProjectIdsToNames({ + restApi, + projectIds: requestedProjectIds, + }); + if (namesResult.isErr()) { + return namesResult; + } + projectNameScope = namesResult.value; + } + + const siteContentResult = await executeAdminInsightsQuery({ + restApi, + datasetName: ADMIN_INSIGHTS_DATASETS.SITE_CONTENT, + query: _buildSiteContentQuery(types, projectNameScope), + }); + if (siteContentResult.isErr()) { + return siteContentResult; + } + + const universe = z + .array(_siteContentRowSchema) + .parse(siteContentResult.value.data ?? []); + + const today = new Date(); + const rows = computeStaleRows({ universe, thresholdDays, today }); + + return new Ok({ + thresholdDays, + totalStaleItems: rows.length, + totalStaleSizeBytes: rows.reduce((sum, r) => sum + (r.size ?? 0), 0), + rows, + }); + }, + }); + }, + constrainSuccessResult: (result) => ({ type: 'success', result }), + }); + } + + // Raw VDS kinds — query is required. The runtime check surfaces a clear error rather than + // punting on an implicit Zod-optional violation. + if (!query) { + return { + isError: true, + content: [ + { + type: 'text', + text: `query is required when kind is "${kind}".`, + }, + ], + }; + } + + return await tool.logAndExecute({ + extra, + args: { kind, query, limit }, + callback: async () => { + const maxResultLimit = configWithOverrides.getMaxResultLimit(tool.name); + const rowLimit = maxResultLimit + ? Math.min(maxResultLimit, limit ?? Number.MAX_SAFE_INTEGER) + : limit; + + return await runAdminInsightsQuery({ + extra, + jwtScopes: tool.requiredApiScopes, + datasetName: kindToDataset(kind), + query, + rowLimit, + }); + }, + constrainSuccessResult: (queryOutput) => ({ type: 'success', result: queryOutput }), + }); + }, + }); + + return tool; +}; + +function kindToDataset(kind: Exclude): AdminInsightsDataset { + switch (kind) { + case 'ts-events': + return ADMIN_INSIGHTS_DATASETS.TS_EVENTS; + case 'site-content': + return ADMIN_INSIGHTS_DATASETS.SITE_CONTENT; + case 'job-performance': + return ADMIN_INSIGHTS_DATASETS.JOB_PERFORMANCE; + } +} diff --git a/src/tools/web/toolName.ts b/src/tools/web/toolName.ts index 431a859f..aba5c505 100644 --- a/src/tools/web/toolName.ts +++ b/src/tools/web/toolName.ts @@ -38,6 +38,8 @@ export const webToolNames = [ 'query-admin-insights-site-content', 'query-admin-insights-job-performance', 'get-stale-content-report', + 'query-admin-insights', + 'delete-content', ] as const; export type WebToolName = (typeof webToolNames)[number]; @@ -53,6 +55,7 @@ export const webToolGroupNames = [ 'users', 'token-management', 'admin-insights', + 'content', ] as const; export type WebToolGroupName = (typeof webToolGroupNames)[number]; @@ -96,11 +99,13 @@ export const webToolGroups = { users: ['list-users'], 'token-management': ['get-embed-token', 'revoke-access-token', 'reset-consent'], 'admin-insights': [ + 'query-admin-insights', 'query-admin-insights-ts-events', 'query-admin-insights-site-content', 'query-admin-insights-job-performance', 'get-stale-content-report', ], + content: ['delete-content'], } as const satisfies Record>; export function isWebToolName(value: unknown): value is WebToolName { diff --git a/src/tools/web/tools.ts b/src/tools/web/tools.ts index d4ee2fbb..9ba38b5c 100644 --- a/src/tools/web/tools.ts +++ b/src/tools/web/tools.ts @@ -1,4 +1,6 @@ +import { getDeleteContentTool } from './_lib/deleteContent.js'; import { getGetStaleContentReportTool } from './adminInsights/getStaleContentReport.js'; +import { getQueryAdminInsightsTool } from './adminInsights/queryAdminInsights.js'; import { getQueryAdminInsightsJobPerformanceTool } from './adminInsights/queryJobPerformance.js'; import { getQueryAdminInsightsSiteContentTool } from './adminInsights/querySiteContent.js'; import { getQueryAdminInsightsTsEventsTool } from './adminInsights/queryTsEvents.js'; @@ -78,4 +80,6 @@ export const webToolFactories = [ getQueryAdminInsightsSiteContentTool, getQueryAdminInsightsJobPerformanceTool, getGetStaleContentReportTool, + getQueryAdminInsightsTool, + getDeleteContentTool, ]; From 3b27e4121f38325824ce9f75115ef7e76896eb04 Mon Sep 17 00:00:00 2001 From: Akash Rastogi Date: Thu, 9 Jul 2026 14:14:23 -0700 Subject: [PATCH 2/9] @W-23375797: fix self-review findings on consolidated tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - delete-content: replace three per-resource MCP scopes with the umbrella tableau:mcp:content:delete so authMiddleware's AND-enforcement doesn't 403 callers who only granted one legacy scope. Callers who need per-resource granularity keep using the legacy delete-{workbook, datasource,extract-refresh-task} tools during the shim window. - query-admin-insights: move the missing-query check inside logAndExecute so failed invocations are logged and product-telemetry emitted instead of being silently dropped by the pre-callback return. - query-admin-insights: honor per-kind MAX_RESULT_LIMITS entries for the four legacy admin-insights tool names by taking the tightest of consolidated cap, legacy cap, and caller-provided limit — prevents operators' existing cap config from silently disappearing on migration. --- src/server/oauth/scopes.ts | 17 ++++---- .../web/adminInsights/queryAdminInsights.ts | 43 +++++++++++-------- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/src/server/oauth/scopes.ts b/src/server/oauth/scopes.ts index d94b279e..672e2f88 100644 --- a/src/server/oauth/scopes.ts +++ b/src/server/oauth/scopes.ts @@ -29,6 +29,7 @@ export type McpScope = | 'tableau:mcp:workbook:delete' | 'tableau:mcp:jobs:read' | 'tableau:mcp:datasource:delete' + | 'tableau:mcp:content:delete' | 'tableau:mcp:users:read'; export type TableauApiScope = @@ -68,6 +69,7 @@ export const DEFAULT_SCOPES_SUPPORTED: ReadonlyArray = [ 'tableau:mcp:workbook:read', 'tableau:mcp:workbook:delete', 'tableau:mcp:content:read', + 'tableau:mcp:content:delete', 'tableau:mcp:view:read', 'tableau:mcp:view:download', 'tableau:mcp:pulse:read', @@ -343,15 +345,14 @@ const toolScopeMap: Record< ]), }, // Consolidated destructive-delete tool (W-23375797). Dispatches on `resourceType` to workbook, - // datasource, or extract-refresh-task. Union of the MCP + API scopes of the three legacy tools it - // replaces — any dispatch path may need any of these. Workbook and datasource paths still route - // through resourceAccessChecker. + // datasource, or extract-refresh-task. Gated on a single umbrella MCP scope + // (`tableau:mcp:content:delete`) that covers all three dispatch paths — declaring the three + // per-resource legacy scopes here would be AND-enforced by authMiddleware and lock out callers + // who only granted one of them. Callers who need per-resource granularity keep using the legacy + // `delete-{workbook,datasource,extract-refresh-task}` tools during the shim window. Workbook and + // datasource paths still route through resourceAccessChecker (union of API scopes preserved). 'delete-content': { - mcp: [ - 'tableau:mcp:workbook:delete', - 'tableau:mcp:datasource:delete', - 'tableau:mcp:tasks:delete', - ], + mcp: ['tableau:mcp:content:delete'], api: new Set([ 'tableau:workbooks:delete', 'tableau:workbook_tags:update', diff --git a/src/tools/web/adminInsights/queryAdminInsights.ts b/src/tools/web/adminInsights/queryAdminInsights.ts index 1ff93249..0281bba5 100644 --- a/src/tools/web/adminInsights/queryAdminInsights.ts +++ b/src/tools/web/adminInsights/queryAdminInsights.ts @@ -3,12 +3,13 @@ import { Ok } from 'ts-results-es'; import { z } from 'zod'; import { getConfig } from '../../../config.js'; -import { AdminOnlyError } from '../../../errors/mcpToolError.js'; +import { AdminOnlyError, ArgsValidationError } from '../../../errors/mcpToolError.js'; import { useRestApi } from '../../../restApiInstance.js'; import { querySchema } from '../../../sdks/tableau/apis/vizqlDataServiceApi.js'; import { WebMcpServer } from '../../../server.web.js'; import { assertAdmin } from '../adminGate.js'; import { WebTool } from '../tool.js'; +import { WebToolName } from '../toolName.js'; import { executeAdminInsightsQuery, runAdminInsightsQuery } from './adminInsightsToolBase.js'; import { _buildSiteContentQuery, @@ -224,28 +225,26 @@ Notes: }); } - // Raw VDS kinds — query is required. The runtime check surfaces a clear error rather than - // punting on an implicit Zod-optional violation. - if (!query) { - return { - isError: true, - content: [ - { - type: 'text', - text: `query is required when kind is "${kind}".`, - }, - ], - }; - } - return await tool.logAndExecute({ extra, args: { kind, query, limit }, callback: async () => { - const maxResultLimit = configWithOverrides.getMaxResultLimit(tool.name); - const rowLimit = maxResultLimit - ? Math.min(maxResultLimit, limit ?? Number.MAX_SAFE_INTEGER) - : limit; + // Raw VDS kinds — query is required. Surfacing this from inside logAndExecute keeps the + // invocation logged and telemetry-emitted; a pre-callback early return would silently + // drop invocations that fail this check. + if (!query) { + return new ArgsValidationError(`query is required when kind is "${kind}".`).toErr(); + } + + // Take the tightest of the consolidated tool cap, the legacy per-kind tool cap, and the + // caller-provided limit — so operators who set `MAX_RESULT_LIMITS=:N` in + // their config keep that cap after migrating callers to the consolidated tool. + const consolidatedCap = configWithOverrides.getMaxResultLimit(tool.name); + const legacyCap = configWithOverrides.getMaxResultLimit(legacyToolByKind[kind]); + const caps = [consolidatedCap, legacyCap, limit].filter( + (v): v is number => typeof v === 'number' && v > 0, + ); + const rowLimit = caps.length > 0 ? Math.min(...caps) : undefined; return await runAdminInsightsQuery({ extra, @@ -273,3 +272,9 @@ function kindToDataset(kind: Exclude): AdminInsightsDatas return ADMIN_INSIGHTS_DATASETS.JOB_PERFORMANCE; } } + +const legacyToolByKind: Record, WebToolName> = { + 'ts-events': 'query-admin-insights-ts-events', + 'site-content': 'query-admin-insights-site-content', + 'job-performance': 'query-admin-insights-job-performance', +}; From efe4ad1e74ffa5e1d770cdf21f676d645c556829 Mon Sep 17 00:00:00 2001 From: Akash Rastogi Date: Fri, 10 Jul 2026 13:33:20 -0700 Subject: [PATCH 3/9] @W-23375797: adapt consolidated stale-content to new resolver shapes PR #473 changed getStaleContentReport's helpers to return richer objects: - _resolveProjectScopeIds now returns {scopeIds, boundedOutOfScopeIds} - _resolveProjectIdsToNames now returns {names, unknownIds} - new _buildProjectIdWarnings helper + _StaleReportWarning type Mirror the legacy get-stale-content-report tool in queryAdminInsights so projectId warnings and the all-invalid widening guard behave the same via kind=stale-content. Re-export the two helpers + type from getStaleContentReport so both tools share one implementation. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../adminInsights/getStaleContentReport.ts | 2 ++ .../web/adminInsights/queryAdminInsights.ts | 29 +++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/tools/web/adminInsights/getStaleContentReport.ts b/src/tools/web/adminInsights/getStaleContentReport.ts index a1de09b6..636de61c 100644 --- a/src/tools/web/adminInsights/getStaleContentReport.ts +++ b/src/tools/web/adminInsights/getStaleContentReport.ts @@ -577,8 +577,10 @@ export const exportedForTesting = { // stale-content report uses; sharing them avoids a second implementation drifting out of sync while // the legacy get-stale-content-report shim remains registered. export { + buildProjectIdWarnings as _buildProjectIdWarnings, buildSiteContentQuery as _buildSiteContentQuery, resolveProjectIdsToNames as _resolveProjectIdsToNames, resolveProjectScopeIds as _resolveProjectScopeIds, siteContentRowSchema as _siteContentRowSchema, }; +export type { StaleReportWarning as _StaleReportWarning }; diff --git a/src/tools/web/adminInsights/queryAdminInsights.ts b/src/tools/web/adminInsights/queryAdminInsights.ts index 0281bba5..7b722c36 100644 --- a/src/tools/web/adminInsights/queryAdminInsights.ts +++ b/src/tools/web/adminInsights/queryAdminInsights.ts @@ -12,10 +12,12 @@ import { WebTool } from '../tool.js'; import { WebToolName } from '../toolName.js'; import { executeAdminInsightsQuery, runAdminInsightsQuery } from './adminInsightsToolBase.js'; import { + _buildProjectIdWarnings, _buildSiteContentQuery, _resolveProjectIdsToNames, _resolveProjectScopeIds, _siteContentRowSchema, + _StaleReportWarning, computeStaleRows, StaleContentRow, } from './getStaleContentReport.js'; @@ -89,6 +91,7 @@ type StaleContentResult = { totalStaleItems: number; totalStaleSizeBytes: number; rows: StaleContentRow[]; + mcp?: { warnings: _StaleReportWarning[] }; }; export const getQueryAdminInsightsTool = (server: WebMcpServer): WebTool => { @@ -166,7 +169,7 @@ Notes: if (kind === 'stale-content') { const thresholdDays = minAgeDays ?? configWithOverrides.staleContentMinAgeDays; const types = itemTypes ?? ['Workbook', 'Datasource']; - const requestedProjectIds = _resolveProjectScopeIds({ + const { scopeIds: requestedProjectIds, boundedOutOfScopeIds } = _resolveProjectScopeIds({ argProjectIds: projectIds, boundedProjectIds: configWithOverrides.boundedContext.projectIds, }); @@ -185,6 +188,7 @@ Notes: } let projectNameScope: ReadonlyArray | null = null; + let unknownProjectIds: ReadonlyArray = []; if (requestedProjectIds) { const namesResult = await _resolveProjectIdsToNames({ restApi, @@ -193,7 +197,27 @@ Notes: if (namesResult.isErr()) { return namesResult; } - projectNameScope = namesResult.value; + projectNameScope = namesResult.value.names; + unknownProjectIds = namesResult.value.unknownIds; + } + + const hasRemainingScope = !!(projectNameScope && projectNameScope.length > 0); + const warnings = _buildProjectIdWarnings({ + boundedOutOfScopeIds, + unknownProjectIds, + hasRemainingScope, + }); + + // Widening guard: a scope was requested but nothing resolved to a real project. + // Return an empty report + warnings instead of falling through to an unscoped query. + if (requestedProjectIds && projectNameScope && projectNameScope.length === 0) { + return new Ok({ + thresholdDays, + totalStaleItems: 0, + totalStaleSizeBytes: 0, + rows: [] as StaleContentRow[], + ...(warnings.length > 0 ? { mcp: { warnings } } : {}), + }); } const siteContentResult = await executeAdminInsightsQuery({ @@ -217,6 +241,7 @@ Notes: totalStaleItems: rows.length, totalStaleSizeBytes: rows.reduce((sum, r) => sum + (r.size ?? 0), 0), rows, + ...(warnings.length > 0 ? { mcp: { warnings } } : {}), }); }, }); From a501850bd142088ea96d09aa769cf729b4f6c04d Mon Sep 17 00:00:00 2001 From: Akash Rastogi Date: Fri, 10 Jul 2026 14:02:27 -0700 Subject: [PATCH 4/9] @W-23375797: fix e2e tool-list test for gated consolidated tools The consolidated query-admin-insights and delete-content tools are disabled unless ADMIN_TOOLS_ENABLED=true, so they don't appear in the server's tool list in the default e2e run. Add them to the adminOnlyTools filter in the default and combined variant "should list tools" tests, matching how the legacy per-kind admin tools are already filtered. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/e2e/server.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/e2e/server.test.ts b/tests/e2e/server.test.ts index e5f5941a..b23859c0 100644 --- a/tests/e2e/server.test.ts +++ b/tests/e2e/server.test.ts @@ -46,6 +46,8 @@ describe('server', () => { 'query-admin-insights-site-content', 'query-admin-insights-job-performance', 'get-stale-content-report', + 'query-admin-insights', + 'delete-content', ]; // These tools are gated by the mcp-apps feature (disabled by default in features.json): // get-embed-token, plus the app-only confirm-* tools. @@ -140,6 +142,8 @@ describe('server', () => { 'query-admin-insights-site-content', 'query-admin-insights-job-performance', 'get-stale-content-report', + 'query-admin-insights', + 'delete-content', ]; // These tools are gated by the mcp-apps feature (disabled by default in features.json): // get-embed-token, plus the app-only confirm-* tools. From b88d43e3a3a442b3554a7baf1baad6b0349f4cfe Mon Sep 17 00:00:00 2001 From: Akash Rastogi Date: Fri, 10 Jul 2026 14:33:36 -0700 Subject: [PATCH 5/9] @W-23375797: lock in tightest-cap via legacy MAX_RESULT_LIMITS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a queryAdminInsights.test.ts case that sets MAX_RESULT_LIMITS=query-admin-insights-ts-events:5, invokes the consolidated tool with kind=ts-events and limit=100, and asserts the underlying VDS query is issued with rowLimit=5 — so the legacy per-kind cap still wins after callers migrate to query-admin-insights. Guards against the silent-migration hazard flagged in the self-review. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../adminInsights/queryAdminInsights.test.ts | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/tools/web/adminInsights/queryAdminInsights.test.ts b/src/tools/web/adminInsights/queryAdminInsights.test.ts index 6cf70bb6..568631cf 100644 --- a/src/tools/web/adminInsights/queryAdminInsights.test.ts +++ b/src/tools/web/adminInsights/queryAdminInsights.test.ts @@ -1,6 +1,7 @@ import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { Err, Ok } from 'ts-results-es'; +import { OverridableConfig } from '../../../overridableConfig.js'; import { Query } from '../../../sdks/tableau/apis/vizqlDataServiceApi.js'; import { WebMcpServer } from '../../../server.web.js'; import { Provider } from '../../../utils/provider.js'; @@ -176,6 +177,29 @@ describe('query-admin-insights tool', () => { } expect(result.content[0].text).toContain('admin'); }); + + // Locks in the tightest-cap fix: a per-tool cap keyed on the LEGACY tool name in + // MAX_RESULT_LIMITS must still bound raw-VDS queries issued through the consolidated + // tool, even when the caller supplies a larger `limit`. Regressing this would silently + // widen the ceiling for operators who migrate callers to `query-admin-insights` while + // keeping their existing MAX_RESULT_LIMITS config. + it('honors a legacy per-kind cap in MAX_RESULT_LIMITS over a larger caller limit', async () => { + mocks.mockQueryDatasource.mockResolvedValue(new Ok({ data: [] })); + + const result = await getToolResult({ + kind: 'ts-events', + query: validQuery, + limit: 100, + maxResultLimits: 'query-admin-insights-ts-events:5', + }); + + expect(result.isError).toBeFalsy(); + expect(mocks.mockQueryDatasource).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ rowLimit: 5 }), + }), + ); + }); }); async function getToolResult(params: { @@ -185,9 +209,16 @@ async function getToolResult(params: { minAgeDays?: number; projectIds?: string[]; itemTypes?: Array<'Workbook' | 'Datasource'>; + maxResultLimits?: string; }): Promise { const tool = getQueryAdminInsightsTool(new WebMcpServer()); const callback = await Provider.from(tool.callback); + const extra = getMockRequestHandlerExtra(); + if (params.maxResultLimits !== undefined) { + extra.getConfigWithOverrides = vi + .fn() + .mockResolvedValue(new OverridableConfig({ MAX_RESULT_LIMITS: params.maxResultLimits })); + } return await callback( { kind: params.kind, @@ -197,6 +228,6 @@ async function getToolResult(params: { projectIds: params.projectIds, itemTypes: params.itemTypes, }, - getMockRequestHandlerExtra(), + extra, ); } From 4bcdcde0ab0e795170a8f6c5d14dc4de89a479d1 Mon Sep 17 00:00:00 2001 From: Akash Rastogi Date: Fri, 10 Jul 2026 14:33:42 -0700 Subject: [PATCH 6/9] @W-23375797: fix oauth-embedded scopes_supported count for umbrella scope The new tableau:mcp:content:delete umbrella scope (added by the self-review fix on delete-content) bumps scopes_supported from 14 to 15. Update the three well-known-endpoint tests to expect the new scope by name and the new length. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/oauth/embedded-authz/oauth.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/oauth/embedded-authz/oauth.test.ts b/tests/oauth/embedded-authz/oauth.test.ts index 2f89a418..8d1bde07 100644 --- a/tests/oauth/embedded-authz/oauth.test.ts +++ b/tests/oauth/embedded-authz/oauth.test.ts @@ -152,9 +152,10 @@ describe('OAuth', () => { 'tableau:mcp:jobs:read', 'tableau:mcp:datasource:delete', 'tableau:mcp:users:read', + 'tableau:mcp:content:delete', ]), ); - expect(response.body.scopes_supported).toHaveLength(14); + expect(response.body.scopes_supported).toHaveLength(15); }); it('should provide a authorization server metadata endpoint for the OAuth 2.1 flow', async () => { @@ -191,9 +192,10 @@ describe('OAuth', () => { 'tableau:mcp:jobs:read', 'tableau:mcp:datasource:delete', 'tableau:mcp:users:read', + 'tableau:mcp:content:delete', ]), ); - expect(response.body.scopes_supported).toHaveLength(14); + expect(response.body.scopes_supported).toHaveLength(15); expect(response.body.token_endpoint_auth_methods_supported).toEqual([ 'none', 'client_secret_basic', @@ -235,9 +237,10 @@ describe('OAuth', () => { 'tableau:mcp:jobs:read', 'tableau:mcp:datasource:delete', 'tableau:mcp:users:read', + 'tableau:mcp:content:delete', ]), ); - expect(response.body.scopes_supported).toHaveLength(14); + expect(response.body.scopes_supported).toHaveLength(15); expect(response.body.token_endpoint_auth_methods_supported).toEqual(['none']); expect(response.body.subject_types_supported).toEqual(['public']); expect(response.body.client_id_metadata_document_supported).toBe(true); From 8f4b369b12dbf9ac68a76f76560f7bc7d5f58bba Mon Sep 17 00:00:00 2001 From: Akash Rastogi Date: Mon, 13 Jul 2026 07:50:37 -0700 Subject: [PATCH 7/9] =?UTF-8?q?@W-23375797:=20address=20review=20=E2=80=94?= =?UTF-8?q?=20trim=20descriptions,=20type=20logAndExecute,=20document=20fl?= =?UTF-8?q?ag-ON=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Trim query-admin-insights description from ~546 to ~120 tokens; remove inline JSON schema + per-kind param tables (Major, Alon). - Trim delete-content description from ~437 to ~100 tokens; remove per-resourceType parameter reference (Major, Alon). - Document that flag-ON preview path writes both TagEvidence AND AppApprovalEvidence in all three resource branches (Minor, Alon). - Add explicit type param to raw-VDS logAndExecute for symmetry with branch (Minor, Alon). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tools/web/_lib/deleteContent.ts | 50 +++++---------- .../web/adminInsights/queryAdminInsights.ts | 62 ++++--------------- 2 files changed, 30 insertions(+), 82 deletions(-) diff --git a/src/tools/web/_lib/deleteContent.ts b/src/tools/web/_lib/deleteContent.ts index 79692c64..bf39ddc2 100644 --- a/src/tools/web/_lib/deleteContent.ts +++ b/src/tools/web/_lib/deleteContent.ts @@ -120,35 +120,15 @@ export const getDeleteContentTool = (server: WebMcpServer): WebTool, - "totalStaleSizeBytes": , - "rows": [ - { - "itemId": "...", - "itemLuid": "..." | null, - "itemType": "Workbook" | "Datasource", - "itemName": "...", - "project": "..." | null, - "ownerEmail": "..." | null, - "createdAt": "ISO date" | null, - "updatedAt": "ISO date" | null, - "lastUsedDate": "ISO date", - "daysSinceLastUse": , - "size": | null, - "neverAccessed": - } - ] -} -\`\`\` +Dispatches on \`kind\`: +- \`ts-events\` / \`site-content\` / \`job-performance\` — raw VDS query. Pass \`query\` (required) + and optional \`limit\`. +- \`stale-content\` — server-side stale-content report. Pass optional \`minAgeDays\`, \`projectIds\`, + \`itemTypes\`; do NOT pass \`query\` or \`limit\`. -Notes: -- The Tableau-managed "Admin Insights" project is excluded from stale-content by design. -- \`Last Accessed At\` is \`null\` for items that have never been accessed; the report ages those - items from \`Created At\` instead. -- The underlying datasource LUIDs are resolved automatically; callers do not pass \`datasourceLuid\`. -- This tool bypasses the standard datasource access checker because Admin Insights datasources - are internal and admin-gated. +Datasource LUIDs are resolved automatically; callers do not pass \`datasourceLuid\`. `.trim(), paramsSchema, annotations: { @@ -250,7 +214,7 @@ Notes: }); } - return await tool.logAndExecute({ + return await tool.logAndExecute({ extra, args: { kind, query, limit }, callback: async () => { From 21d03afaba1b97a291b5f278e912f71cf97863c5 Mon Sep 17 00:00:00 2001 From: Akash Rastogi Date: Mon, 13 Jul 2026 08:16:16 -0700 Subject: [PATCH 8/9] @W-23375797: export QueryOutput type from adminInsightsToolBase The previous commit imported QueryOutput in queryAdminInsights.ts from adminInsightsToolBase.js, but that module only used it locally without re-exporting. Add `export type { QueryOutput }` so the consolidated tool's explicit type param compiles. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tools/web/adminInsights/adminInsightsToolBase.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/tools/web/adminInsights/adminInsightsToolBase.ts b/src/tools/web/adminInsights/adminInsightsToolBase.ts index 9226a01e..9e456c1a 100644 --- a/src/tools/web/adminInsights/adminInsightsToolBase.ts +++ b/src/tools/web/adminInsights/adminInsightsToolBase.ts @@ -11,9 +11,11 @@ import { useRestApi } from '../../../restApiInstance.js'; import { Datasource, Query, - QueryOutput, + type QueryOutput, QueryRequest, } from '../../../sdks/tableau/apis/vizqlDataServiceApi.js'; + +export type { QueryOutput }; import { RestApi } from '../../../sdks/tableau/restApi.js'; import { TableauApiScope } from '../../../server/oauth/scopes.js'; import { assertAdmin } from '../adminGate.js'; From 9f15e6b5c5472f4c7395957c005184b2b1dcea1f Mon Sep 17 00:00:00 2001 From: Akash Rastogi Date: Mon, 13 Jul 2026 10:39:15 -0700 Subject: [PATCH 9/9] docs: add documentation pages for query-admin-insights and delete-content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/docs/tools/admin-insights/query-admin-insights.md — consolidated tool - docs/docs/tools/content/delete-content.md — consolidated tool - docs/docs/tools/content/_category_.json — sidebar category - docs/docs/intro.md — 2 new table rows Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/docs/intro.md | 2 + .../admin-insights/query-admin-insights.md | 190 ++++++++++++++++++ docs/docs/tools/content/_category_.json | 8 + docs/docs/tools/content/delete-content.md | 154 ++++++++++++++ 4 files changed, 354 insertions(+) create mode 100644 docs/docs/tools/admin-insights/query-admin-insights.md create mode 100644 docs/docs/tools/content/_category_.json create mode 100644 docs/docs/tools/content/delete-content.md diff --git a/docs/docs/intro.md b/docs/docs/intro.md index 6ed1d601..890e3130 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -65,6 +65,8 @@ Slack channel in the Tableau #DataDev workspace. | [query-admin-insights-site-content](tools/admin-insights/query-admin-insights-site-content.md) | Admin-only. Issues a VDS query against the Admin Insights `Site Content` datasource ([VDS API][vds]) | All SKUs | | [query-admin-insights-job-performance](tools/admin-insights/query-admin-insights-job-performance.md) | Admin-only. Issues a VDS query against the Admin Insights `Job Performance` datasource ([VDS API][vds]) | All SKUs | | [get-stale-content-report](tools/admin-insights/get-stale-content-report.md) | Admin-only. Deterministic stale-content report from `Site Content` ([VDS API][vds]) | All SKUs | +| [query-admin-insights](tools/admin-insights/query-admin-insights.md) | Admin-only. Consolidated admin-insights tool — dispatches on `kind` to TS Events, Site Content, Job Performance, or stale-content report ([VDS API][vds]) | All SKUs | +| [delete-content](tools/content/delete-content.md) | Admin-only. Two-phase (preview/confirm) delete of a workbook, data source, or extract refresh task ([REST API][delete-workbook], [REST API][delete-datasource], [REST API][delete-extract-refresh-task]) | All SKUs | \* The `get-datasource-metadata` tool relies on both the VizQL Data Service and the Metadata API to get rich metadata about a data source. Only sites with Data Management entitlements will be able to execute the Metadata API calls, though the tool will remain functional without it. diff --git a/docs/docs/tools/admin-insights/query-admin-insights.md b/docs/docs/tools/admin-insights/query-admin-insights.md new file mode 100644 index 00000000..f9ac1801 --- /dev/null +++ b/docs/docs/tools/admin-insights/query-admin-insights.md @@ -0,0 +1,190 @@ +--- +sidebar_position: 5 +--- + +# Query Admin Insights + +Consolidated admin-insights tool that queries all three Tableau Cloud Admin Insights datasources +and the deterministic stale-content report through a single entry point. Dispatches on `kind` to +one of four backends: + +- `ts-events` — raw VDS query against the `TS Events` datasource (audit events: access, publish, + update, delete) +- `site-content` — raw VDS query against the `Site Content` datasource (content metadata, + ownership, sizes) +- `job-performance` — raw VDS query against the `Job Performance` datasource (extract refresh and + subscription execution history) +- `stale-content` — server-side anti-join that returns already-filtered stale rows with no + client-side math required + +The tool is admin-only — it is registered only when `ADMIN_TOOLS_ENABLED=true`, and at request +time it verifies the caller's site role and rejects anything below +`SiteAdministratorCreator` / `SiteAdministratorExplorer` / `ServerAdministrator`. Admin Insights +datasource LUIDs are resolved automatically; callers do not pass `datasourceLuid`. + +:::note[Replaces legacy tools] +This tool is a superset of the four legacy admin-insights tools +(`query-admin-insights-ts-events`, `query-admin-insights-site-content`, +`query-admin-insights-job-performance`, `get-stale-content-report`), which remain registered as +back-compat shims for one release cycle and share the underlying implementation. +::: + +## APIs called + +- [Query Datasource (VDS)](https://help.tableau.com/current/api/vizql-data-service/en-us/reference/index.html#tag/HeadlessBI/operation/QueryDatasource) + — issues the VDS query for `ts-events`, `site-content`, `job-performance`, and the `stale-content` backend +- [Query Data Sources (REST)](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#query_data_sources) + — used internally to resolve Admin Insights dataset LUIDs +- [Query Projects (REST)](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_projects.htm#query_projects) + — used internally for `stale-content` to resolve project LUIDs to names +- [Get User on Site (REST)](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#get_user_on_site) + — used internally for the admin gate + +## Required arguments + +### `kind` + +Which admin-insights backend to query: +- `ts-events` — raw VDS query against TS Events +- `site-content` — raw VDS query against Site Content +- `job-performance` — raw VDS query against Job Performance +- `stale-content` — deterministic stale-content anti-join + +### `query` + +**Required when `kind` is `ts-events`, `site-content`, or `job-performance`.** +Ignored when `kind` is `stale-content`. + +A fully formed VDS [`query`](https://help.tableau.com/current/api/vizql-data-service/en-us/reference/index.html#tag/HeadlessBI/operation/QueryDatasource) +object: `fields`, `filters`, `parameters`. The schema mirrors the schema accepted by the +[`query-datasource`](../data-qna/query-datasource.md) tool. + +Example (TS Events — last-access per item): + +```json +{ + "kind": "ts-events", + "query": { + "fields": [ + { "fieldCaption": "Item Id" }, + { "fieldCaption": "Item Type" }, + { "fieldCaption": "Event Date", "function": "MAX", "fieldAlias": "last_access" } + ], + "filters": [ + { + "field": { "fieldCaption": "Event Type" }, + "filterType": "SET", + "values": ["Access"], + "exclude": false + } + ] + }, + "limit": 500 +} +``` + +## Optional arguments + +### `limit` + +The maximum number of rows to return. Applied when `kind` is `ts-events`, `site-content`, or +`job-performance`; **ignored** for `stale-content`. + +The effective row limit is the **tightest** of: +1. The consolidated tool cap (`MAX_RESULT_LIMITS=query-admin-insights:N`) +2. The legacy per-kind tool cap (`MAX_RESULT_LIMITS=query-admin-insights-ts-events:N`) +3. The caller-supplied `limit` + +This ensures operators who set per-kind limits in their config keep those caps after migrating +callers to the consolidated tool. + +See also: [`MAX_RESULT_LIMIT`](../../configuration/mcp-config/env-vars.md#max_result_limit) + +
+ +### `minAgeDays` + +**For `kind="stale-content"` only.** Minimum days since last access for content to be considered +stale. Falls back to the server-configured +[`STALE_CONTENT_MIN_AGE_DAYS`](../../configuration/mcp-config/env-vars.md), which defaults to `90`. + +Range: `1`–`3650`. + +Example: `30` + +
+ +### `projectIds` + +**For `kind="stale-content"` only.** Optional list of project LUIDs to scope the report to. +Resolved to project names via the REST API. Invalid or out-of-scope LUIDs are reported in +`mcp.warnings` rather than silently dropped. If none resolve, the tool returns an empty report. + +Example: `["af59ee84-a375-4cb4-84b9-eaa7864f59fb"]` + +
+ +### `itemTypes` + +**For `kind="stale-content"` only.** Optional filter for item types. Defaults to +`["Workbook", "Datasource"]`. + +Example: `["Datasource"]` + +## Notes and caveats + +- Tableau Cloud TS Events lookback caps at **90 days by default** (365 days with Advanced + Management). Items beyond the lookback cannot be distinguished on last-access timestamps. +- Field captions differ between datasources — e.g. `Item Id` (TS Events) vs `Item ID` (Site + Content). Inspect with [`get-datasource-metadata`](../data-qna/get-datasource-metadata.md) + when in doubt. +- The `stale-content` backend excludes the Tableau-managed `Admin Insights` project by design. +- `Last Accessed At` is `null` for never-accessed items; the stale-content backend ages those + from `Created At` and flags them `neverAccessed: true`. +- This tool intentionally bypasses the standard datasource access checker because Admin Insights + datasources are internal/known and admin-gated independently. + +## Example results + +### Raw VDS query (`kind: "ts-events"`) + +```json +{ + "data": [ + { "Item Id": "5092107", "Item Type": "Datasource", "last_access": "2026-04-15T00:00:00Z" }, + { "Item Id": "1412202", "Item Type": "Workbook", "last_access": "2026-05-08T21:12:45Z" } + ] +} +``` + +### Stale-content report (`kind: "stale-content"`) + +```json +{ + "thresholdDays": 90, + "totalStaleItems": 2, + "totalStaleSizeBytes": 5586253, + "rows": [ + { + "itemId": "1412202", + "itemType": "Workbook", + "itemName": "World Indicators", + "project": "Samples", + "ownerEmail": "owner@example.com", + "createdAt": "2025-09-02T23:26:02", + "updatedAt": "2025-09-02T23:26:02", + "lastUsedDate": "2025-09-02T23:26:02", + "daysSinceLastUse": 259, + "size": 796179, + "neverAccessed": true + } + ] +} +``` + +## Related + +- [`query-admin-insights-ts-events`](./query-admin-insights-ts-events.md) — legacy TS Events tool (shim) +- [`query-admin-insights-site-content`](./query-admin-insights-site-content.md) — legacy Site Content tool (shim) +- [`query-admin-insights-job-performance`](./query-admin-insights-job-performance.md) — legacy Job Performance tool (shim) +- [`get-stale-content-report`](./get-stale-content-report.md) — legacy stale-content tool (shim) diff --git a/docs/docs/tools/content/_category_.json b/docs/docs/tools/content/_category_.json new file mode 100644 index 00000000..9a2a4a4a --- /dev/null +++ b/docs/docs/tools/content/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Content Management", + "position": 8, + "link": { + "type": "generated-index", + "description": "Admin-only tools for managing content lifecycle on Tableau Cloud — deletion, archival, and recycle-bin operations. Gated by the `ADMIN_TOOLS_ENABLED` environment variable and a request-time site-role check." + } +} diff --git a/docs/docs/tools/content/delete-content.md b/docs/docs/tools/content/delete-content.md new file mode 100644 index 00000000..7364b82a --- /dev/null +++ b/docs/docs/tools/content/delete-content.md @@ -0,0 +1,154 @@ +--- +sidebar_position: 1 +--- + +# Delete Content + +Consolidated destructive-delete tool that permanently deletes a workbook, published data source, +or extract refresh task. Dispatches on `resourceType`: + +- `workbook` — deletes a workbook (recoverable via recycle bin on Tableau Cloud) +- `datasource` — deletes a published data source (recoverable via recycle bin; warns on downstream dependents) +- `extract-refresh-task` — deletes an extract refresh task schedule (permanent, not recoverable) + +The tool is **admin-only** — it is registered only when `ADMIN_TOOLS_ENABLED=true`, and at +request time it verifies the caller's site role and rejects anything below +`SiteAdministratorCreator` / `SiteAdministratorExplorer` / `ServerAdministrator`. + +:::note[Replaces legacy tools] +This tool is a superset of the three legacy delete tools (`delete-workbook`, +`delete-datasource`, `delete-extract-refresh-task`), which remain registered as back-compat +shims for one release cycle and share the underlying implementation. +::: + +## Two-phase safety + +The tool is **two-phase** to keep the destructive action safe: + +1. **Preview** (default — `confirm` omitted or `false`): + - For `workbook` / `datasource`: tags the resource with `pending-deletion` (reversible, + visible in the Tableau UI), reports identity, project, and owner. + - For `extract-refresh-task`: mints a single-use `confirmationToken` and reports task + metadata. + - Does **not** delete anything. + +2. **Confirm** (`confirm: true`): + - For `workbook` / `datasource`: re-fetches the resource and verifies the pending-deletion + tag is present before deleting (server-authoritative gate — cannot be bypassed by guessing). + - For `extract-refresh-task`: verifies the `confirmationToken` matches the nonce from the + preview. + - Performs the deletion. + +:::warning Human confirmation required +Between the preview and the confirm, the calling agent is instructed to surface the resource +identity to the user and obtain explicit approval. This is a prompt-level expectation; the +tag/nonce gate proves a preview ran but cannot observe whether a human actually approved. + +When the `mcp-apps` feature flag is enabled, the model-driven `confirm: true` path is **closed** +entirely — deletion requires a human gesture in the in-iframe confirm panel. +::: + +## Tool scoping + +This tool honors the same [tool-scoping](../../configuration/mcp-config/tool-scoping.md) rules as +the read tools. If the server is configured with a bounded context (`INCLUDE_WORKBOOK_IDS`, +`INCLUDE_PROJECT_IDS`, `INCLUDE_DATASOURCE_IDS`, `INCLUDE_TAGS`), a resource outside that scope +cannot be previewed or deleted — the request is rejected before any side effects. + +## APIs called + +### Workbook + +- [Add Tags to Workbook](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#add_tags_to_workbook) (preview) +- [Query Workbook](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#query_workbook) (preview + confirm verification) +- [Delete Workbook](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#delete_workbook) (confirm) + +### Datasource + +- [Add Tags to Data Source](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#add_tags_to_data_source) (preview) +- [Query Data Source](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#query_data_source) (preview + confirm verification) +- [Delete Data Source](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#delete_data_source) (confirm) +- [Metadata API — lineage query](https://help.tableau.com/current/api/metadata_api/en-us/index.html) (preview — downstream-dependent warning) + +### Extract Refresh Task + +- [Get Extract Refresh Task](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#get_extract_refresh_task) (preview + confirm verification) +- [Delete Extract Refresh Task](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#delete_extract_refresh_task) (confirm) + +### Common + +- [Get User on Site (REST)](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#get_user_on_site) — admin gate + +## Required arguments + +### `resourceType` + +The kind of resource to delete: `"workbook"`, `"datasource"`, or `"extract-refresh-task"`. + +### `resourceId` + +The LUID of the workbook or data source, or the UUID of the extract refresh task. + +Example: `"222ea993-9391-4910-a167-56b3d19b4e3b"` + +## Optional arguments + +### `confirm` + +When omitted or `false`, runs the non-destructive preview. When `true`, permanently deletes — +but only if the prior-preview evidence is present (tag for workbook/datasource, +`confirmationToken` for extract-refresh-task). + +Example: `true` + +
+ +### `tag` + +**For `resourceType="workbook"` or `"datasource"` only.** The pending-deletion tag label. +Reversible and visible in the Tableau UI. Defaults to `pending-deletion`. + +Example: `"stale-pending-deletion"` + +
+ +### `confirmationToken` + +**For `resourceType="extract-refresh-task"` only.** The single-use token returned by a prior +preview call. Required when `confirm` is `true`; ignored otherwise. + +## Side effects + +- **Preview (workbook/datasource)** adds the pending-deletion tag. Reversible. +- **Preview (extract-refresh-task)** mints an ephemeral nonce (no visible side effect on the task). +- **Confirm (workbook/datasource)** removes the resource. On Tableau Cloud it goes to the + [recycle bin](https://help.tableau.com/current/pro/desktop/en-us/recycle_bin.htm) and can be + restored for a limited time. +- **Confirm (extract-refresh-task)** permanently deletes the task schedule. Not recoverable. + +## Example + +### Preview a workbook deletion + +```json +{ + "resourceType": "workbook", + "resourceId": "222ea993-9391-4910-a167-56b3d19b4e3b" +} +``` + +### Confirm the deletion + +```json +{ + "resourceType": "workbook", + "resourceId": "222ea993-9391-4910-a167-56b3d19b4e3b", + "confirm": true +} +``` + +## Related + +- [`delete-workbook`](../workbooks/delete-workbook.md) — legacy workbook-only delete (shim) +- [`delete-datasource`](../data-qna/delete-datasource.md) — legacy datasource-only delete (shim) +- [`delete-extract-refresh-task`](../tasks/delete-extract-refresh-task.md) — legacy task-only delete (shim)