Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/mcp-table-limit-having-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperdx/api': patch
---

feat(mcp): `clickstack_table` takes optional `limit` and `having`, and orders grouped results by the first select item, descending, when no `orderBy` is given
38 changes: 37 additions & 1 deletion packages/api/src/mcp/__tests__/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
whereLanguageSchema,
whereSchema,
} from '@/mcp/tools/query/schemas';
import { resolveOrderBy } from '@/mcp/tools/query/table';
import { defaultOrderBy, resolveOrderBy } from '@/mcp/tools/query/table';
import { getErrorCategory } from '@/mcp/utils/errors';

describe('parseTimeRange', () => {
Expand Down Expand Up @@ -1430,3 +1430,39 @@ describe('where / whereLanguage descriptions', () => {
);
});
});

describe('defaultOrderBy', () => {
it('orders a grouped table by the first select item, descending', () => {
expect(defaultOrderBy([{}, {}], 'SpanName', 'table')).toBe(
'1 DESC, SpanName ASC',
);
});

it('uses the first select item alias when it has one', () => {
expect(defaultOrderBy([{ alias: 'Errors' }], 'SpanName', 'table')).toBe(
'Errors DESC, SpanName ASC',
);
expect(
defaultOrderBy([{ alias: 'P95 Latency' }], 'SpanName', 'table'),
).toBe('"P95 Latency" DESC, SpanName ASC');
});

it('quotes an alias with surrounding spaces as SELECT renders it', () => {
expect(defaultOrderBy([{ alias: ' Errors' }], 'SpanName', 'table')).toBe(
'" Errors" DESC, SpanName ASC',
);
expect(defaultOrderBy([{ alias: ' ' }], 'SpanName', 'table')).toBe(
'1 DESC, SpanName ASC',
);
});

it('adds no order without a groupBy, for other shapes, or for metric selects', () => {
expect(defaultOrderBy([{}], undefined, 'table')).toBeUndefined();
expect(defaultOrderBy([{}], ' ', 'table')).toBeUndefined();
expect(defaultOrderBy([{}], 'SpanName', 'pie')).toBeUndefined();
expect(defaultOrderBy([{}], 'SpanName', 'number')).toBeUndefined();
expect(
defaultOrderBy([{ metricType: 'gauge' }], 'SpanName', 'table'),
).toBeUndefined();
});
});
126 changes: 126 additions & 0 deletions packages/api/src/mcp/__tests__/queryTool.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,132 @@ describe('MCP Query Tools', () => {
expect(result.isError).toBeFalsy();
expect(result.content).toHaveLength(1);
});

describe('having, limit and the default order', () => {
const now = Date.now();
const tableArgs = () => ({
sourceId: logSource._id.toString(),
select: [{ aggFn: 'count', alias: 'n' }],
groupBy: 'ServiceName',
startTime: new Date(now - 60 * 60 * 1000).toISOString(),
endTime: new Date(now + 60 * 1000).toISOString(),
});
const rows = (result: Awaited<ReturnType<typeof callTool>>) => {
const parsed: { result: { data: { ServiceName: string }[] } } =
JSON.parse(getFirstText(result));
return parsed.result.data;
};

beforeEach(async () => {
// a: 1 row, b: 5 rows, c: 3 rows
const logs: Parameters<typeof bulkInsertLogs>[0] = [];
for (const [service, count] of [
['a', 1],
['b', 5],
['c', 3],
] as const) {
for (let i = 0; i < count; i++) {
logs.push({
Body: `event ${i}`,
ServiceName: service,
SeverityText: 'INFO',
Timestamp: new Date(now - 60 * 1000 + i),
});
}
}
await bulkInsertLogs(logs);
});

it('orders by the first select item, descending, when orderBy is omitted', async () => {
const result = await callTool(client, 'clickstack_table', tableArgs());
expect(result.isError).toBeFalsy();
expect(rows(result).map(r => r.ServiceName)).toEqual(['b', 'c', 'a']);
});

it('orders by position when the first select item has no alias', async () => {
const result = await callTool(client, 'clickstack_table', {
...tableArgs(),
select: [{ aggFn: 'count' }],
});
expect(result.isError).toBeFalsy();
expect(rows(result).map(r => r.ServiceName)).toEqual(['b', 'c', 'a']);
});

it('keeps an explicit orderBy', async () => {
const result = await callTool(client, 'clickstack_table', {
...tableArgs(),
orderBy: 'ServiceName ASC',
});
expect(rows(result).map(r => r.ServiceName)).toEqual(['a', 'b', 'c']);
});

it('applies limit after ordering', async () => {
const result = await callTool(client, 'clickstack_table', {
...tableArgs(),
limit: 2,
});
expect(rows(result).map(r => r.ServiceName)).toEqual(['b', 'c']);
});

it('applies having to the groups', async () => {
const result = await callTool(client, 'clickstack_table', {
...tableArgs(),
having: 'n > 1',
});
expect(rows(result).map(r => r.ServiceName)).toEqual(['b', 'c']);
});

it('orders by a quoted alias with a space, by default and explicitly', async () => {
const select = [{ aggFn: 'count', alias: 'Line Count' }];
const byDefault = await callTool(client, 'clickstack_table', {
...tableArgs(),
select,
});
expect(byDefault.isError).toBeFalsy();
expect(rows(byDefault).map(r => r.ServiceName)).toEqual([
'b',
'c',
'a',
]);
const explicit = await callTool(client, 'clickstack_table', {
...tableArgs(),
select,
orderBy: '"Line Count" ASC',
});
expect(explicit.isError).toBeFalsy();
expect(rows(explicit).map(r => r.ServiceName)).toEqual(['a', 'c', 'b']);
});

it.each(['pie', 'bar'] as const)(
'keeps the largest groups with limit on shape "%s"',
async shape => {
const result = await callTool(client, 'clickstack_table', {
...tableArgs(),
shape,
limit: 2,
});
expect(result.isError).toBeFalsy();
expect(rows(result).map(r => r.ServiceName)).toEqual(['b', 'c']);
},
);

it.each(['pie', 'bar'] as const)(
'applies having on shape "%s"',
async shape => {
const result = await callTool(client, 'clickstack_table', {
...tableArgs(),
shape,
having: 'n > 1',
});
expect(result.isError).toBeFalsy();
expect(
rows(result)
.map(r => r.ServiceName)
.sort(),
).toEqual(['b', 'c']);
},
);
});
});

// ─── clickstack_search ─────────────────────────────────────────────────────────
Expand Down
18 changes: 18 additions & 0 deletions packages/api/src/mcp/tools/query/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,10 @@ export async function runConfigTile(
variables?: ChartVariable[];
/** Serialized size the result is trimmed to. Defaults to trimToolResponse's limit. */
maxResponseChars?: number;
/** Post-aggregation SQL HAVING condition for builder tiles (MCP table tool). */
having?: string;
/** SQL LIMIT for builder tiles that aren't searches (MCP table tool). */
limit?: number;
/** Source lookup for raw SQL tiles, so a batch can share one per-call cache. */
lookupSource?: (sourceId: string) => ReturnType<typeof getSource>;
},
Expand Down Expand Up @@ -583,9 +587,23 @@ export async function runConfigTile(
? { granularity: options?.granularity ?? 'auto' }
: {};

// having/limit come from the MCP table tool's own inputs, not from the
// tile, so dashboard tiles and the app keep their current behaviour.
const tableToolOverrides = isSearch
? {}
: {
...(options?.having

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — A whitespace-only having passes the truthiness check

options?.having ? … lets a value like ' ' through, so it gets set on the chart config with havingLanguage 'sql'. The having schema in table.ts has no .trim() or .min(1). Add .trim() to the schema (or check options.having.trim()) so an empty condition is dropped rather than depending on the renderer's isNonEmptyWhereExpr guard.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

? { having: options.having, havingLanguage: 'sql' as const }
: {}),
...(options?.limit
? { limit: { limit: options.limit, offset: 0 } }
: {}),
};

const chartConfig = {
...builderConfig,
...searchOverrides,
...tableToolOverrides,
...metricSelectOverrides,
...granularityOverride,
from: {
Expand Down
67 changes: 64 additions & 3 deletions packages/api/src/mcp/tools/query/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,32 @@ const tableSchema = z.object({
),
whereLanguage: whereLanguageSchema,
groupBy: groupBySchema,
orderBy: orderBySchema,
having: z
.string()
.trim()
.max(10000)
.optional()
.describe(
'Post-aggregation SQL HAVING condition, applied after grouping. ' +
'Give the select item an alias and reference it, e.g. alias "Errors" and having "Errors > 10". ' +
'A bare aggregate such as count() ignores the top-level where.',
),
orderBy: orderBySchema.describe(
'Sort results by this column. For shape "table" on a non-metric source with groupBy set, ' +
'defaults to the first select item descending, then the groupBy columns. ' +
'When ordering by an alias that contains spaces or special characters, ' +
`wrap the alias in quotes: e.g. '"P95 Latency" DESC'.`,
),
limit: z
.number()
.int()
.positive()
.max(10000)
.optional()
.describe(
'Maximum number of rows (SQL LIMIT), applied after ordering. Omit to return every group. ' +
'Metric sources get no default order, so set orderBy as well, or the kept groups are arbitrary.',
),
startTime: startTimeSchema,
endTime: endTimeSchema,
});
Expand Down Expand Up @@ -196,6 +221,38 @@ export function resolveOrderBy(
return orderBy;
}

/**
* Default ORDER BY for grouped tables when the caller sets none: the first
* select item, descending, so trimmed or limited results keep the largest
* groups. MCP only; dashboard tiles and the app are unchanged.
*
* Unaliased select items have no name to refer to, so the first one is
* referenced by position (`1`): the rendered SELECT lists the select items
* first, then the groupBy columns. Metric sources are skipped: their SQL is
* built from CTEs, and `increase` already orders its top-N groups.
*
* The groupBy columns follow as a tiebreak, so a `limit` keeps the same groups
* on every call, as convertToCategoricalChartConfig does for pie/bar.
*
* @internal Exported for testing only.
*/
export function defaultOrderBy(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — defaultOrderBy re-implements the default ordering in convertToCategoricalChartConfig, without its tiebreak

convertToCategoricalChartConfig (packages/common-utils/src/core/utils.ts:1070-1097) already adds a default 'first select item DESC' order. It quotes the alias, or sets it to 'Value' when there is none, and adds the groupBy ASC as a stable tiebreak. defaultOrderBy does the same job a second way (positional 1, no tiebreak). So with limit, groups that tie on the first value can come back differently on each call. Pull the shared logic into one common-utils helper and use it for both, or at least add the groupBy ASC tiebreak here so limited results are deterministic.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

selectItems: { alias?: string; metricType?: string }[],
groupBy: string | undefined,
displayType: 'table' | 'number' | 'pie' | 'bar',
): string | undefined {
if (displayType !== 'table' || !groupBy?.trim()) return undefined;
if (selectItems.length === 0 || selectItems.some(s => s.metricType)) {
return undefined;
}
// Quote the alias exactly as SELECT renders it; trim only to test emptiness.
const alias = selectItems[0].alias;
const first = alias?.trim()
? `${quoteAliasForOrderBy(alias)} DESC`
: '1 DESC';
return `${first}, ${groupBy.trim()} ASC`;
}

// ─── Tool registration ───────────────────────────────────────────────────────

export function registerTable({ context, registerTool }: ToolRegistrar) {
Expand Down Expand Up @@ -270,19 +327,23 @@ export function registerTable({ context, registerTool }: ToolRegistrar) {
const { items: selectItems, warnings: mergeWarnings } =
mergeWhereIntoSelectItems(select, input.where, input.whereLanguage);

const groupBy = displayType === 'number' ? undefined : input.groupBy;
const tile = buildTile('MCP Table', 12, 4, {
displayType,
sourceId: input.sourceId,
select: selectItems,
groupBy: displayType === 'number' ? undefined : input.groupBy,
orderBy: resolveOrderBy(input.orderBy, selectItems),
groupBy,
orderBy:
resolveOrderBy(input.orderBy, selectItems) ??
defaultOrderBy(selectItems, groupBy, displayType),
});

const result = await runConfigTile(
teamId.toString(),
tile,
startDate,
endDate,
{ having: input.having, limit: input.limit },
);

// Surface language-mismatch warnings so the agent knows the top-level
Expand Down
Loading