Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/sdks/tableau/types/dataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { tagsSchema } from './tags.js';
export const dataSourceSchema = z.object({
id: z.string(),
name: z.string(),
// contentUrl is the URL-safe slug that is UNIQUE per site (case-sensitively).
// Unlike name, it disambiguates same-named datasources — needed to map a Desktop
// workbook's <repository-location id="..."> to exactly one published datasource.
contentUrl: z.string().optional(),
description: z.string().optional(),
project: projectSchema,
owner: z
Expand Down
12 changes: 12 additions & 0 deletions src/server/oauth/scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ const toolScopeMap: Record<
mcp: ['tableau:mcp:datasource:read'],
api: new Set(['tableau:content:read', 'tableau:mcp_site_settings:read']),
},
'resolve-datasource-luid': {
mcp: ['tableau:mcp:datasource:read'],
api: new Set(['tableau:content:read', 'tableau:mcp_site_settings:read']),
},
'list-extract-refresh-tasks': {
mcp: ['tableau:mcp:tasks:read'],
api: new Set(['tableau:tasks:read', 'tableau:users:read']),
Expand Down Expand Up @@ -273,6 +277,14 @@ const toolScopeMap: Record<
mcp: ['tableau:mcp:insight:create'],
api: new Set(['tableau:insight_brief:create', 'tableau:mcp_site_settings:read']),
},
'generate-chiron-insight-from-datasource-context': {
mcp: ['tableau:mcp:insight:create'],
api: new Set([
'tableau:insight_brief:create',
'tableau:insights:read',
'tableau:mcp_site_settings:read',
]),
},
'search-content': {
mcp: ['tableau:mcp:content:read'],
api: new Set(['tableau:content:read', 'tableau:mcp_site_settings:read']),
Expand Down
86 changes: 86 additions & 0 deletions src/tools/web/datasources/resolveDatasourceLuid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';

import { WebMcpServer } from '../../../server.web.js';
import { stubDefaultEnvVars } from '../../../testShared.js';
import invariant from '../../../utils/invariant.js';
import { Provider } from '../../../utils/provider.js';
import { getMockRequestHandlerExtra } from '../toolContext.mock.js';
import { getResolveDatasourceLuidTool } from './resolveDatasourceLuid.js';

const mocks = vi.hoisted(() => ({ mockListDatasources: vi.fn() }));

vi.mock('../../../restApiInstance.js', () => ({
useRestApi: vi.fn().mockImplementation(async ({ callback }) =>
callback({
siteId: 'site-1',
datasourcesMethods: { listDatasources: mocks.mockListDatasources },
}),
),
}));

const proj = (name: string): { id: string; name: string } => ({ id: `p-${name}`, name });
const tags = { tag: [] };

// The case-insensitive server filter returns BOTH "Superstore" and "superstore".
const superstoreCaseCollision = [
{
id: '78667ee4',
name: 'Superstore',
contentUrl: 'Superstore',
project: proj('Tableau Samples'),
tags,
},
{
id: 'e27f64e8',
name: 'Superstore',
contentUrl: 'superstore',
project: proj('Personal Work'),
tags,
},
];

describe('getResolveDatasourceLuidTool', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
stubDefaultEnvVars();
mocks.mockListDatasources.mockResolvedValue({
pagination: { pageNumber: '1', pageSize: '100', totalAvailable: '2' },
datasources: superstoreCaseCollision,
});
});

it('has the correct tool name', () => {
const tool = getResolveDatasourceLuidTool(new WebMcpServer());
expect(tool.name).toBe('resolve-datasource-luid');
});

it('exact case-sensitive match resolves the unique LUID despite case-insensitive filter', async () => {
const result = await run({ contentUrl: 'Superstore' });
expect(result.isError).toBe(false);
invariant(result.content[0].type === 'text');
const payload = JSON.parse(result.content[0].text);
expect(payload.luid).toBe('78667ee4');
expect(payload.contentUrl).toBe('Superstore');
expect(payload.projectName).toBe('Tableau Samples');
});

it('lowercase contentUrl resolves the other datasource', async () => {
const result = await run({ contentUrl: 'superstore' });
expect(result.isError).toBe(false);
invariant(result.content[0].type === 'text');
expect(JSON.parse(result.content[0].text).luid).toBe('e27f64e8');
});

it('returns not-found for an unknown contentUrl', async () => {
const result = await run({ contentUrl: 'DoesNotExist' });
invariant(result.content[0].type === 'text');
expect(result.content[0].text).toContain('No published datasource found');
});

async function run(args: { contentUrl: string; projectName?: string }): Promise<CallToolResult> {
const tool = getResolveDatasourceLuidTool(new WebMcpServer());
const callback = await Provider.from(tool.callback);
return await callback(args, getMockRequestHandlerExtra());

Check failure on line 84 in src/tools/web/datasources/resolveDatasourceLuid.test.ts

View workflow job for this annotation

GitHub Actions / build (>=22.7.5 <23)

Argument of type '{ contentUrl: string; projectName?: string | undefined; }' is not assignable to parameter of type 'ShapeOutput<{ contentUrl: ZodString; projectName: ZodOptional<ZodString>; }>'.

Check failure on line 84 in src/tools/web/datasources/resolveDatasourceLuid.test.ts

View workflow job for this annotation

GitHub Actions / build (24.x)

Argument of type '{ contentUrl: string; projectName?: string | undefined; }' is not assignable to parameter of type 'ShapeOutput<{ contentUrl: ZodString; projectName: ZodOptional<ZodString>; }>'.
}
});
141 changes: 141 additions & 0 deletions src/tools/web/datasources/resolveDatasourceLuid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { Ok } from 'ts-results-es';
import { z } from 'zod';

import { useRestApi } from '../../../restApiInstance.js';
import { DataSource } from '../../../sdks/tableau/types/dataSource.js';
import { WebMcpServer } from '../../../server.web.js';
import { paginate } from '../../../utils/paginate.js';
import { ConstrainedResult, WebTool } from '../tool.js';

const paramsSchema = {
contentUrl: z.string().min(1),
projectName: z.string().optional(),
};

type ResolvedDatasource = {
luid: string;
name: string;
contentUrl: string;
projectName?: string;
};

/**
* Resolve a published datasource's LUID from its `contentUrl` — the URL-safe slug a
* Tableau Desktop workbook exposes in `<repository-location id="...">`.
*
* Why this exists (and why `list-datasources` alone is not enough):
* - `name` is NOT unique per site (e.g. three datasources all named "Superstore").
* - `contentUrl` IS unique per site, BUT the REST `contentUrl:eq:` filter is
* case-INSENSITIVE, so it can still return >1 (e.g. "Superstore" and "superstore").
* This tool applies an exact, case-SENSITIVE post-filter (optionally narrowed by
* project) so the caller gets exactly one LUID or a clear ambiguity/not-found result.
*/
export const getResolveDatasourceLuidTool = (
server: WebMcpServer,
): WebTool<typeof paramsSchema> => {
const resolveDatasourceLuidTool = new WebTool({
server,
name: 'resolve-datasource-luid',
description: `
Resolve a published datasource's LUID from its \`contentUrl\` (the unique, URL-safe slug).

Use this to map a Tableau Desktop workbook's published datasource to a Cloud/Server LUID
before calling Pulse/Chiron insight tools. The Desktop workbook exposes the datasource as
\`<repository-location id="<contentUrl>" site="<site>"/>\` — pass that \`id\` as \`contentUrl\`.

Why not \`list-datasources\`? Datasource \`name\` is not unique per site, and the REST
\`contentUrl\` filter is case-insensitive; this tool exact-matches (case-sensitive) so you
get exactly one datasource.

**Parameters:**
- \`contentUrl\` (required): exact, case-sensitive contentUrl from the Desktop repository-location id.
- \`projectName\` (optional): narrow further if two datasources somehow collide.

**Returns:** \`{ luid, name, contentUrl, projectName }\` for the single match, or an
explicit not-found / ambiguous message.
`,
paramsSchema,
annotations: {
title: 'Resolve Datasource LUID',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
callback: async ({ contentUrl, projectName }, extra): Promise<CallToolResult> => {
const configWithOverrides = await extra.getConfigWithOverrides();
return await resolveDatasourceLuidTool.logAndExecute<Array<DataSource>>({
extra,
args: { contentUrl, projectName },
callback: async () => {
const datasources = await useRestApi({
...extra,
jwtScopes: resolveDatasourceLuidTool.requiredApiScopes,
callback: async (restApi) =>
await paginate({
pageConfig: { pageSize: 100, limit: 1000 },
getDataFn: async (pageConfig) => {
const { pagination, datasources: data } =
await restApi.datasourcesMethods.listDatasources({
siteId: restApi.siteId,
// Server filter is case-insensitive; we exact-match below.
filter: `contentUrl:eq:${contentUrl}`,
pageSize: pageConfig.pageSize,
pageNumber: pageConfig.pageNumber,
});
return { pagination, data };
},
}),
});

return new Ok(datasources);
},
constrainSuccessResult: (datasources): ConstrainedResult<Array<DataSource>> => {
const { datasourceIds } = configWithOverrides.boundedContext;

let matches = datasources.filter((ds) => ds.contentUrl === contentUrl);
if (projectName) {
matches = matches.filter((ds) => ds.project.name === projectName);
}
if (datasourceIds) {
matches = matches.filter((ds) => datasourceIds.has(ds.id));
}

if (matches.length === 0) {
return {
type: 'empty',
message: `No published datasource found with contentUrl "${contentUrl}"${
projectName ? ` in project "${projectName}"` : ''
}. Confirm the contentUrl (exact case) from the Desktop repository-location and that you have access.`,
};
}

if (matches.length > 1) {
const candidates = matches
.map((ds) => `${ds.name} (LUID ${ds.id}, project "${ds.project.name}")`)
.join('; ');
return {
type: 'error',
message: `Ambiguous: ${matches.length} datasources share contentUrl "${contentUrl}". Re-call with projectName to disambiguate. Candidates: ${candidates}`,
};
}

return { type: 'success', result: matches };
},
getSuccessResult: (matches) => {
const ds = matches[0];
const resolved: ResolvedDatasource = {
luid: ds.id,
name: ds.name,
contentUrl: ds.contentUrl ?? contentUrl,
projectName: ds.project.name,
};
return { isError: false, content: [{ type: 'text', text: JSON.stringify(resolved) }] };
},
});
},
});

return resolveDatasourceLuidTool;
};
Loading
Loading