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
6 changes: 6 additions & 0 deletions .changeset/mcp-sql-tile-macro-hints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@hyperdx/api": patch
"@hyperdx/common-utils": patch
---

fix: Nudge agents towards macros in raw SQL tiles
111 changes: 111 additions & 0 deletions packages/api/src/mcp/__tests__/dashboards/patchDashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,4 +650,115 @@ describe('MCP Dashboard Tools - clickstack_patch_dashboard', () => {
{ operator: 'gte', value: 500, color: 'chart-error' },
]);
});

describe('raw SQL macro warnings', () => {
// Patching a tile to a macro-less raw SQL config succeeds (non-blocking)
// but surfaces an advisory `warnings` array on the response.
it('returns a non-blocking warning when a patched tile omits macros', async () => {
const sourceId = ctx.traceSource._id.toString();
const connectionId = ctx.connection._id.toString();
const createResult = await callTool(
ctx.client!,
'clickstack_save_dashboard',
{
name: 'Patch to static SQL',
tiles: [
{
name: 'Builder Tile',
config: {
displayType: 'number',
sourceId,
select: [{ aggFn: 'count' }],
},
},
],
},
);
const created = JSON.parse(getFirstText(createResult));
const tileId = created.tiles[0].id;

const patchResult = await callTool(
ctx.client!,
'clickstack_patch_dashboard',
{
dashboardId: created.id,
tileId,
tile: {
name: 'Static SQL',
config: {
configType: 'sql',
displayType: 'table',
connectionId,
sqlTemplate: 'SELECT 1 AS value LIMIT 1',
},
},
},
);

// Non-blocking: the patch applies and the tile is persisted.
expect(patchResult.isError).toBeFalsy();
const output = JSON.parse(getFirstText(patchResult));
expect(output.patchedTile.name).toBe('Static SQL');
const dashboard = await Dashboard.findById(created.id);
const persistedConfig = dashboard?.tiles?.[0]?.config as {
sqlTemplate?: string;
};
expect(persistedConfig?.sqlTemplate).toBe('SELECT 1 AS value LIMIT 1');

expect(Array.isArray(output.warnings)).toBe(true);
expect(output.warnings).toHaveLength(1);
expect(output.warnings[0]).toContain('Static SQL');
expect(output.warnings[0]).toContain('$__timeFilter');
expect(output.warnings[0]).toContain('strongly recommended');
});

it('omits warnings when a patched raw SQL tile uses all recommended macros', async () => {
const sourceId = ctx.traceSource._id.toString();
const connectionId = ctx.connection._id.toString();
const createResult = await callTool(
ctx.client!,
'clickstack_save_dashboard',
{
name: 'Patch to macro SQL',
tiles: [
{
name: 'Builder Tile',
config: {
displayType: 'number',
sourceId,
select: [{ aggFn: 'count' }],
},
},
],
},
);
const created = JSON.parse(getFirstText(createResult));
const tileId = created.tiles[0].id;

const patchResult = await callTool(
ctx.client!,
'clickstack_patch_dashboard',
{
dashboardId: created.id,
tileId,
tile: {
name: 'Macro SQL',
config: {
configType: 'sql',
displayType: 'table',
connectionId,
sourceId,
sqlTemplate:
'SELECT ServiceName, count() AS c FROM $__sourceTable ' +
'WHERE $__timeFilter(Timestamp) AND $__filters GROUP BY ServiceName LIMIT 10',
},
},
},
);

expect(patchResult.isError).toBeFalsy();
const output = JSON.parse(getFirstText(patchResult));
expect(output.warnings).toBeUndefined();
});
});
});
84 changes: 84 additions & 0 deletions packages/api/src/mcp/__tests__/dashboards/queryTile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,88 @@ describe('MCP Dashboard Tools - clickstack_query_tile', () => {
expect(result.isError).toBeFalsy();
expect(result.content).toHaveLength(1);
});

describe('raw SQL macro warnings', () => {
it('attaches a non-blocking warning when a raw SQL tile omits macros', async () => {
const connectionId = ctx.connection._id.toString();
const createResult = await callTool(
ctx.client!,
'clickstack_save_dashboard',
{
name: 'Query macro-less SQL tile',
tiles: [
{
name: 'Static SQL',
config: {
configType: 'sql',
displayType: 'table',
connectionId,
// Runs fine against ClickHouse but uses no time-range /
// filter / source macros.
sqlTemplate: 'SELECT 1 AS value LIMIT 1',
},
},
],
},
);
const dashboard = JSON.parse(getFirstText(createResult));

const result = await callTool(ctx.client!, 'clickstack_query_tile', {
dashboardId: dashboard.id,
tileId: dashboard.tiles[0].id,
startTime: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
endTime: new Date().toISOString(),
});

// Non-blocking: the query still executes and returns its row.
expect(result.isError).toBeFalsy();
const parsed = JSON.parse(getFirstText(result));
expect(parsed.result).toBeDefined();

// The advisory rides alongside the result.
expect(Array.isArray(parsed.warnings)).toBe(true);
expect(parsed.warnings).toHaveLength(1);
expect(parsed.warnings[0]).toContain('Static SQL');
expect(parsed.warnings[0]).toContain('$__timeFilter');
expect(parsed.warnings[0]).toContain('strongly recommended');
});

it('does not attach warnings when a raw SQL tile uses all recommended macros', async () => {
const connectionId = ctx.connection._id.toString();
const sourceId = ctx.traceSource._id.toString();
const createResult = await callTool(
ctx.client!,
'clickstack_save_dashboard',
{
name: 'Query macro SQL tile',
tiles: [
{
name: 'Macro SQL',
config: {
configType: 'sql',
displayType: 'table',
connectionId,
sourceId,
sqlTemplate:
'SELECT ServiceName, count() AS c FROM $__sourceTable ' +
'WHERE $__timeFilter(Timestamp) AND $__filters GROUP BY ServiceName LIMIT 10',
},
},
],
},
);
const dashboard = JSON.parse(getFirstText(createResult));

const result = await callTool(ctx.client!, 'clickstack_query_tile', {
dashboardId: dashboard.id,
tileId: dashboard.tiles[0].id,
startTime: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
endTime: new Date().toISOString(),
});

expect(result.isError).toBeFalsy();
const parsed = JSON.parse(getFirstText(result));
expect(parsed.warnings).toBeUndefined();
});
});
});
151 changes: 151 additions & 0 deletions packages/api/src/mcp/__tests__/dashboards/saveDashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1142,6 +1142,157 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => {
});
});

describe('raw SQL macro warnings', () => {
// These warnings are advisory: a raw SQL tile that omits the dashboard
// time-range / filter / source macros still saves successfully, but the
// response carries a `warnings` array so the agent can spot a tile that
// won't react to dashboard controls.
it('returns a non-blocking warning when a raw SQL tile omits macros on create', async () => {
const connectionId = ctx.connection._id.toString();
const result = await callTool(ctx.client!, 'clickstack_save_dashboard', {
name: 'SQL Dashboard without macros',
tiles: [
{
name: 'Static SQL',
config: {
configType: 'sql',
displayType: 'table',
connectionId,
sqlTemplate: 'SELECT 1 AS value LIMIT 1',
},
},
],
});

// Non-blocking: the save succeeds and the dashboard is persisted.
expect(result.isError).toBeFalsy();
const output = JSON.parse(getFirstText(result));
expect(output.id).toBeDefined();
expect(output.tiles).toHaveLength(1);
const dashboard = await Dashboard.findById(output.id);
expect(dashboard).not.toBeNull();

// The advisory names the tile and recommends the missing macros.
expect(Array.isArray(output.warnings)).toBe(true);
expect(output.warnings).toHaveLength(1);
expect(output.warnings[0]).toContain('Static SQL');
expect(output.warnings[0]).toContain('$__timeFilter');
expect(output.warnings[0]).toContain('$__filters');
expect(output.warnings[0]).toContain('$__sourceTable');
expect(output.warnings[0]).toContain('strongly recommended');
});

it('omits warnings when a raw SQL tile uses all recommended macros', async () => {
const connectionId = ctx.connection._id.toString();
const sourceId = ctx.traceSource._id.toString();
const result = await callTool(ctx.client!, 'clickstack_save_dashboard', {
name: 'SQL Dashboard with macros',
tiles: [
{
name: 'Macro SQL',
config: {
configType: 'sql',
displayType: 'table',
connectionId,
sourceId,
sqlTemplate:
'SELECT ServiceName, count() AS c FROM $__sourceTable ' +
'WHERE $__timeFilter(Timestamp) AND $__filters GROUP BY ServiceName LIMIT 10',
},
},
],
});

expect(result.isError).toBeFalsy();
const output = JSON.parse(getFirstText(result));
expect(output.warnings).toBeUndefined();
});

it('flags a missing interval macro on a time-series raw SQL tile', async () => {
const connectionId = ctx.connection._id.toString();
const sourceId = ctx.traceSource._id.toString();
const result = await callTool(ctx.client!, 'clickstack_save_dashboard', {
name: 'SQL line tile without interval',
tiles: [
{
name: 'Line SQL',
config: {
configType: 'sql',
displayType: 'line',
connectionId,
sourceId,
// Has time-range + filters + source table, but buckets by a
// fixed minute instead of $__timeInterval, so granularity is
// ignored.
sqlTemplate:
'SELECT toStartOfMinute(Timestamp) AS ts, count() AS c FROM $__sourceTable ' +
'WHERE $__timeFilter(Timestamp) AND $__filters GROUP BY ts ORDER BY ts',
},
},
],
});

expect(result.isError).toBeFalsy();
const output = JSON.parse(getFirstText(result));
expect(Array.isArray(output.warnings)).toBe(true);
expect(output.warnings).toHaveLength(1);
expect(output.warnings[0]).toContain('$__timeInterval');
});

it('returns a non-blocking warning when a raw SQL tile omits macros on update', async () => {
const connectionId = ctx.connection._id.toString();
const sourceId = ctx.traceSource._id.toString();

// Create with a well-formed builder tile (no warnings expected).
const createResult = await callTool(
ctx.client!,
'clickstack_save_dashboard',
{
name: 'Update to static SQL',
tiles: [
{
name: 'Builder Tile',
config: {
displayType: 'number',
sourceId,
select: [{ aggFn: 'count' }],
},
},
],
},
);
const created = JSON.parse(getFirstText(createResult));
expect(created.warnings).toBeUndefined();

// Update, replacing the tile with a macro-less raw SQL tile.
const updateResult = await callTool(
ctx.client!,
'clickstack_save_dashboard',
{
id: created.id,
name: 'Update to static SQL',
tiles: [
{
name: 'Static SQL',
config: {
configType: 'sql',
displayType: 'table',
connectionId,
sqlTemplate: 'SELECT 1 AS value LIMIT 1',
},
},
],
},
);

expect(updateResult.isError).toBeFalsy();
const updated = JSON.parse(getFirstText(updateResult));
expect(updated.id).toBe(created.id);
expect(Array.isArray(updated.warnings)).toBe(true);
expect(updated.warnings[0]).toContain('Static SQL');
});
});

describe('containers and tabs', () => {
// Mirrors the v2 external-API "Containers and tabs" describe block so
// the MCP path enforces the same 5 cross-field rules: container id
Expand Down
Loading
Loading