Skip to content
6 changes: 6 additions & 0 deletions src/desktop/metadata/field-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,11 @@ export function listAvailableFields(
const datasourceName = datasource['@_name'];
if (!datasourceName || datasourceName === 'Parameters') continue;

// A PUBLISHED datasource carries a repository-location whose id is its
// contentUrl (the input resolve-datasource-luid needs). Embedded/local
// datasources have no repository-location, so this stays undefined.
const contentUrl = datasource['repository-location']?.['@_id'];

// Map to track all columns by name (to deduplicate)
const columnMap = new Map<
string,
Expand Down Expand Up @@ -444,6 +449,7 @@ export function listAvailableFields(

const fieldRef: FieldReference = {
datasource: datasourceName,
contentUrl: contentUrl,
columnName: columnName,
columnInstanceName: constructedInstance,
derivation: defaultAgg,
Expand Down
4 changes: 4 additions & 0 deletions src/desktop/metadata/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export interface FieldInfo {

export interface FieldReference {
datasource: string;
// Published datasource's contentUrl (from the workbook's repository-location);
// the input `resolve-datasource-luid` needs to get the server LUID. Undefined
// for embedded/local datasources, which have no server copy.
contentUrl?: string;
columnName: string;
columnInstanceName: string;
derivation: AggregationType;
Expand Down
171 changes: 170 additions & 1 deletion src/tools/desktop/fields/listAvailableFields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const resultSchema = z.object({
const mockFields = [
{
datasource: 'Sample - Superstore',
contentUrl: 'SuperstoreDS',
columnName: '[Profit]',
columnInstanceName: '[sum:Profit:qk]',
derivation: 'Sum',
Expand All @@ -38,6 +39,7 @@ const mockFields = [
},
{
datasource: 'Sample - Superstore',
contentUrl: 'SuperstoreDS',
columnName: '[Category]',
columnInstanceName: '[none:Category:nk]',
derivation: 'None',
Expand Down Expand Up @@ -247,18 +249,185 @@ describe('listAvailableFieldsTool', () => {
expect(body.message).toContain('No fields found');
expect(body.fields).toHaveLength(0);
});

const slimBodySchema = z.object({
count: z.number(),
datasources: z.array(
z.object({
datasource: z.string().nullable(),
contentUrl: z.string().optional(),
fields: z.array(
z.object({
caption: z.string(),
role: z.string(),
datatype: z.string().optional(),
}),
),
}),
),
});

it('verbosity=slim returns compact grouped fields with no ASCII table', async () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue('<workbook/>');
vi.mocked(metadataModule.listAvailableFields).mockReturnValue(mockFields as any);

const result = await getResult({ workbookFile: '/workbook.xml', verbosity: 'slim' });

expect(result.isError).toBe(false);
invariant(result.content[0].type === 'text');
const body = slimBodySchema.parse(JSON.parse(result.content[0].text));

// No human-readable table; a single datasource is still the grouped shape
// (one group) so callers always parse the same structure.
expect('message' in body).toBe(false);
expect('fields' in body).toBe(false);
expect(body.count).toBe(2);
expect(body.datasources).toHaveLength(1);
expect(body.datasources[0].datasource).toBe('Sample - Superstore');
// Published datasource → contentUrl surfaced once on the group (the input
// resolve-datasource-luid needs), not repeated per field.
expect(body.datasources[0].contentUrl).toBe('SuperstoreDS');
const groupFields = body.datasources[0].fields;
expect(groupFields).toHaveLength(2);
// caption falls back to the bracket-stripped columnName when caption is absent.
expect(groupFields[0]).toMatchObject({ caption: 'Profit', role: 'measure', datatype: 'real' });
expect(groupFields[1]).toMatchObject({
caption: 'Category',
role: 'dimension',
datatype: 'string',
});
// Per-field metadata not needed for picking is omitted: column_ref (authoring),
// and the redundant/near-duplicate name, datasource (it's on the group), semanticRole.
const first = groupFields[0] as Record<string, unknown>;
expect(first.column_ref).toBeUndefined();
expect(first.name).toBeUndefined();
expect(first.datasource).toBeUndefined();
expect(first.semanticRole).toBeUndefined();
});

it('verbosity=slim on an empty workbook returns count 0 and no datasource groups', async () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue('<workbook/>');
vi.mocked(metadataModule.listAvailableFields).mockReturnValue([]);

const result = await getResult({ workbookFile: '/workbook.xml', verbosity: 'slim' });

expect(result.isError).toBe(false);
invariant(result.content[0].type === 'text');
const body = slimBodySchema.parse(JSON.parse(result.content[0].text));
expect(body.count).toBe(0);
expect(body.datasources).toHaveLength(0);
});

it('verbosity=slim groups fields by datasource across multiple datasources', async () => {
// Two datasources, including a SAME caption ('Profit') in each — the case
// where hoisting fields[0].datasource would misattribute the second and
// erase the only disambiguator. Grouping carries the datasource once per
// group rather than repeating it on every field.
// Two datasources: 'Sample - Superstore' is PUBLISHED (has a contentUrl),
// 'Finance Extract' is EMBEDDED (contentUrl undefined). Both have a 'Profit'.
const multiDatasourceFields = [
{
...mockFields[0],
datasource: 'Sample - Superstore',
contentUrl: 'SuperstoreDS',
caption: 'Profit',
},
{
...mockFields[1],
datasource: 'Sample - Superstore',
contentUrl: 'SuperstoreDS',
caption: 'Category',
},
{ ...mockFields[0], datasource: 'Finance Extract', contentUrl: undefined, caption: 'Profit' },
{ ...mockFields[1], datasource: 'Finance Extract', contentUrl: undefined, caption: 'Region' },
];
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue('<workbook/>');
vi.mocked(metadataModule.listAvailableFields).mockReturnValue(multiDatasourceFields as any);

const result = await getResult({ workbookFile: '/workbook.xml', verbosity: 'slim' });

expect(result.isError).toBe(false);
invariant(result.content[0].type === 'text');
const body = slimBodySchema.parse(JSON.parse(result.content[0].text));

// Grouped shape (no top-level `datasource`, no flat `fields`) when >1 datasource.
expect('datasource' in body).toBe(false);
expect('fields' in body).toBe(false);
expect(body.count).toBe(4);
expect(body.datasources.map((g) => g.datasource)).toEqual([
'Sample - Superstore',
'Finance Extract',
]);
// contentUrl per group: present for the published one, omitted for the embedded one.
expect(body.datasources[0].contentUrl).toBe('SuperstoreDS');
expect(body.datasources[1].contentUrl).toBeUndefined();
// The two same-caption 'Profit' fields stay distinct — one per group.
expect(body.datasources[0].fields.map((f) => f.caption)).toEqual(['Profit', 'Category']);
expect(body.datasources[1].fields.map((f) => f.caption)).toEqual(['Profit', 'Region']);
// The datasource string is NOT repeated on individual fields.
expect((body.datasources[0].fields[0] as Record<string, unknown>).datasource).toBeUndefined();
});

it('verbosity=slim without workbookFile groups fields from the live session workbook', async () => {
// Slim over the live-session path (session, no workbookFile): reads the
// resolved live workbook, never the file cache, and still returns the
// grouped slim shape. 'Fresh DS' is embedded (no contentUrl).
vi.mocked(getWorkbookXmlModule.getWorkbookXml).mockResolvedValue(Ok(LIVE_XML));
vi.mocked(metadataModule.listAvailableFields).mockReturnValue(mockLiveFields as any);
const mockExecutor = {} as any;
const extra = {
...getMockRequestHandlerExtra(),
getExecutor: vi.fn().mockResolvedValue(mockExecutor),
};

const result = await getResult({ session: SESSION, verbosity: 'slim', extra });

expect(result.isError).toBe(false);
invariant(result.content[0].type === 'text');
const body = slimBodySchema.parse(JSON.parse(result.content[0].text));

// Grouped slim shape, not the ASCII-table shape.
expect('message' in body).toBe(false);
expect('fields' in body).toBe(false);
expect(body.count).toBe(1);
expect(body.datasources).toHaveLength(1);
expect(body.datasources[0].datasource).toBe('Fresh DS');
// Embedded datasource → no contentUrl on the group.
expect(body.datasources[0].contentUrl).toBeUndefined();
expect(body.datasources[0].fields).toEqual([
{ caption: 'Sales', role: 'measure', datatype: 'real' },
]);

// Live-session path: read from the executor's workbook, never the file cache.
expect(existsSync).not.toHaveBeenCalled();
expect(readFileSync).not.toHaveBeenCalled();
expect(extra.getExecutor).toHaveBeenCalledWith(SESSION);
expect(getWorkbookXmlModule.getWorkbookXml).toHaveBeenCalledWith({
executor: mockExecutor,
signal: extra.signal,
});
expect(metadataModule.listAvailableFields).toHaveBeenCalledWith(LIVE_XML);
});
});

async function getResult({
workbookFile,
session,
verbosity,
extra,
}: {
workbookFile?: string;
session?: string;
verbosity?: 'slim' | 'full';
extra?: ReturnType<typeof getMockRequestHandlerExtra>;
}): Promise<CallToolResult> {
const tool = getListAvailableFieldsTool(new DesktopMcpServer());
const callback = await Provider.from(tool.callback);
return await callback({ session, workbookFile }, extra ?? getMockRequestHandlerExtra());
return await callback(
{ session, workbookFile, verbosity },
extra ?? getMockRequestHandlerExtra(),
);
}
72 changes: 69 additions & 3 deletions src/tools/desktop/fields/listAvailableFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ const paramsSchema = {
.string()
.optional()
.describe('Optional cached workbook file; omit for the live session workbook.'),
verbosity: z
.enum(['slim', 'full'])
.optional()
.describe(
"'full' (default): table + column_ref. 'slim': compact fields by datasource, no column_ref (use resolve-field).",
),
};

class WorkbookFileNotFoundError extends McpToolError {
Expand Down Expand Up @@ -65,6 +71,30 @@ const tableauDatatypeLabel = (datatype?: string): string => {
}
};

interface SlimField {
caption: string;
role: string;
datatype: string | undefined;
}

/** One datasource's slim fields. Slim always groups by datasource. */
interface SlimDatasourceGroup {
datasource: string | null;
// The datasource's contentUrl when it's a published datasource — the input
// resolve-datasource-luid needs to get the server LUID. Omitted for
// embedded/local datasources (no server copy, so no contentUrl).
contentUrl?: string;
fields: SlimField[];
}

type ListAvailableFieldsResult =
| { message: string; fields: ReturnType<typeof listAvailableFields> }
// Slim: fields grouped by datasource so the datasource name is carried once
// per group, never repeated on every field (keeps slim small). Always this
// shape — even for a single datasource (one group) — so callers parse one
// consistent structure.
| { count: number; datasources: SlimDatasourceGroup[] };

const title = 'List All Available Fields in Workbook Datasources';
export const getListAvailableFieldsTool = (
server: DesktopMcpServer,
Expand All @@ -86,10 +116,10 @@ export const getListAvailableFieldsTool = (
idempotentHint: true,
openWorldHint: false,
},
callback: async ({ session, workbookFile }, extra): Promise<CallToolResult> => {
return await listAvailableFieldsTool.logAndExecute({
callback: async ({ session, workbookFile, verbosity }, extra): Promise<CallToolResult> => {
return await listAvailableFieldsTool.logAndExecute<ListAvailableFieldsResult>({
extra,
args: { session, workbookFile },
args: { session, workbookFile, verbosity },
callback: async () => {
const cacheWorkbookFile = workbookFile?.trim() ? workbookFile : undefined;

Expand Down Expand Up @@ -137,6 +167,42 @@ export const getListAvailableFieldsTool = (

const fields = listAvailableFields(workbookXml);

// Slim: caption/role/datatype only, no table — small enough to avoid
// the inline-output cap. Get column_ref via resolve-field.
//
// `listAvailableFields` spans ALL datasources (one flat array, each
// field carrying its own datasource). Slim always GROUPS by
// datasource — one group per datasource, in first-seen order — so the
// datasource name is carried once per group rather than hoisted (which
// would misattribute every field past the first in a multi-datasource
// workbook and erase the only disambiguator for same-caption fields)
// or repeated per field (which would bloat slim). A single-datasource
// workbook is just one group, so callers always parse one shape.
if (verbosity === 'slim') {
const toSlimField = (f: (typeof fields)[number]): SlimField => ({
caption: f.caption || f.columnName.replace(/^\[|\]$/g, ''),
role: f.role,
datatype: f.datatype,
});

// Group by datasource name (first-seen order). Each group also
// carries the datasource's contentUrl (same for all its fields) —
// present only for published datasources.
const groups = new Map<string | null, SlimDatasourceGroup>();
for (const f of fields) {
let group = groups.get(f.datasource);
if (!group) {
group = { datasource: f.datasource, contentUrl: f.contentUrl, fields: [] };
groups.set(f.datasource, group);
}
group.fields.push(toSlimField(f));
}
return new Ok({
count: fields.length,
datasources: Array.from(groups.values()),
});
}

if (fields.length === 0) {
return new Ok({
message: 'No fields found in the workbook datasources.',
Expand Down
Loading