Skip to content

Commit 95fa364

Browse files
authored
feat(mcp): support savedFilterValues in dashboard tools (#3533)
1 parent 769c0de commit 95fa364

8 files changed

Lines changed: 498 additions & 16 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hyperdx/api': minor
3+
---
4+
5+
feat(mcp): support setting a dashboard's `savedFilterValues` via the MCP dashboard tools

‎packages/api/src/mcp/__tests__/dashboards/saveDashboard.int.test.ts‎

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3684,4 +3684,347 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => {
36843684
expect(getFirstText(result)).toContain('only valid for gauge metrics');
36853685
});
36863686
});
3687+
3688+
describe('savedFilterValues', () => {
3689+
const tile = (sourceId: string) => ({
3690+
name: 'Volume',
3691+
config: {
3692+
displayType: 'line' as const,
3693+
sourceId,
3694+
select: [{ aggFn: 'count' as const, where: '' }],
3695+
},
3696+
});
3697+
3698+
it('persists savedFilterValues on create and echoes them back', async () => {
3699+
const sourceId = ctx.traceSource._id.toString();
3700+
3701+
const result = await callTool(ctx.client!, 'clickstack_save_dashboard', {
3702+
name: 'Defaults on create',
3703+
tiles: [tile(sourceId)],
3704+
filters: [
3705+
{
3706+
type: 'QUERY_EXPRESSION',
3707+
name: 'Service',
3708+
expression: 'ServiceName',
3709+
sourceId,
3710+
whereLanguage: 'sql',
3711+
isVariableEnabled: true,
3712+
variableName: 'service',
3713+
},
3714+
],
3715+
savedFilterValues: [
3716+
{ type: 'sql', condition: "ServiceName IN ('api')" },
3717+
{ type: 'variable', name: 'service', values: ['api', 'web'] },
3718+
],
3719+
});
3720+
3721+
expect(result.isError).toBeFalsy();
3722+
const created = JSON.parse(getFirstText(result));
3723+
expect(created.savedFilterValues).toEqual([
3724+
{ type: 'sql', condition: "ServiceName IN ('api')" },
3725+
{ type: 'variable', name: 'service', values: ['api', 'web'] },
3726+
]);
3727+
3728+
const stored = await Dashboard.findById(created.id);
3729+
expect(stored?.savedFilterValues).toEqual([
3730+
{ type: 'sql', condition: "ServiceName IN ('api')" },
3731+
{ type: 'variable', name: 'service', values: ['api', 'web'] },
3732+
]);
3733+
});
3734+
3735+
it('defaults an omitted type to sql on a condition entry', async () => {
3736+
const sourceId = ctx.traceSource._id.toString();
3737+
3738+
const result = await callTool(ctx.client!, 'clickstack_save_dashboard', {
3739+
name: 'Implicit sql type',
3740+
tiles: [tile(sourceId)],
3741+
savedFilterValues: [{ condition: "ServiceName IN ('api')" }],
3742+
});
3743+
3744+
expect(result.isError).toBeFalsy();
3745+
const created = JSON.parse(getFirstText(result));
3746+
expect(created.savedFilterValues).toEqual([
3747+
{ type: 'sql', condition: "ServiceName IN ('api')" },
3748+
]);
3749+
});
3750+
3751+
it('returns savedFilterValues from clickstack_get_dashboard', async () => {
3752+
const sourceId = ctx.traceSource._id.toString();
3753+
3754+
const createResult = await callTool(
3755+
ctx.client!,
3756+
'clickstack_save_dashboard',
3757+
{
3758+
name: 'Defaults readable on get',
3759+
tiles: [tile(sourceId)],
3760+
savedFilterValues: [{ type: 'sql', condition: 'StatusCode = 200' }],
3761+
},
3762+
);
3763+
const created = JSON.parse(getFirstText(createResult));
3764+
3765+
const getResult = await callTool(
3766+
ctx.client!,
3767+
'clickstack_get_dashboard',
3768+
{ id: created.id },
3769+
);
3770+
const fetched = JSON.parse(getFirstText(getResult));
3771+
expect(fetched.savedFilterValues).toEqual([
3772+
{ type: 'sql', condition: 'StatusCode = 200' },
3773+
]);
3774+
});
3775+
3776+
it('leaves saved defaults untouched when the update omits the field', async () => {
3777+
const sourceId = ctx.traceSource._id.toString();
3778+
3779+
const createResult = await callTool(
3780+
ctx.client!,
3781+
'clickstack_save_dashboard',
3782+
{
3783+
name: 'Keep defaults on update',
3784+
tiles: [tile(sourceId)],
3785+
savedFilterValues: [{ type: 'sql', condition: 'StatusCode = 200' }],
3786+
},
3787+
);
3788+
const created = JSON.parse(getFirstText(createResult));
3789+
3790+
const updateResult = await callTool(
3791+
ctx.client!,
3792+
'clickstack_save_dashboard',
3793+
{
3794+
id: created.id,
3795+
name: 'Keep defaults on update (renamed)',
3796+
tiles: [tile(sourceId)],
3797+
},
3798+
);
3799+
expect(updateResult.isError).toBeFalsy();
3800+
const updated = JSON.parse(getFirstText(updateResult));
3801+
expect(updated.savedFilterValues).toEqual([
3802+
{ type: 'sql', condition: 'StatusCode = 200' },
3803+
]);
3804+
});
3805+
3806+
it('clears saved defaults when the update passes an empty array', async () => {
3807+
const sourceId = ctx.traceSource._id.toString();
3808+
3809+
const createResult = await callTool(
3810+
ctx.client!,
3811+
'clickstack_save_dashboard',
3812+
{
3813+
name: 'Clear defaults on update',
3814+
tiles: [tile(sourceId)],
3815+
savedFilterValues: [{ type: 'sql', condition: 'StatusCode = 200' }],
3816+
},
3817+
);
3818+
const created = JSON.parse(getFirstText(createResult));
3819+
3820+
const updateResult = await callTool(
3821+
ctx.client!,
3822+
'clickstack_save_dashboard',
3823+
{
3824+
id: created.id,
3825+
name: 'Clear defaults on update',
3826+
tiles: [tile(sourceId)],
3827+
savedFilterValues: [],
3828+
},
3829+
);
3830+
expect(updateResult.isError).toBeFalsy();
3831+
const updated = JSON.parse(getFirstText(updateResult));
3832+
expect(updated.savedFilterValues).toEqual([]);
3833+
3834+
const stored = await Dashboard.findById(created.id);
3835+
expect(stored?.savedFilterValues).toEqual([]);
3836+
});
3837+
3838+
it('sets savedFilterValues via clickstack_patch_dashboard', async () => {
3839+
const sourceId = ctx.traceSource._id.toString();
3840+
3841+
const createResult = await callTool(
3842+
ctx.client!,
3843+
'clickstack_save_dashboard',
3844+
{
3845+
name: 'Patch defaults',
3846+
tiles: [tile(sourceId)],
3847+
},
3848+
);
3849+
const created = JSON.parse(getFirstText(createResult));
3850+
3851+
const patchResult = await callTool(
3852+
ctx.client!,
3853+
'clickstack_patch_dashboard',
3854+
{
3855+
dashboardId: created.id,
3856+
savedFilterValues: [
3857+
{ type: 'sql', condition: "ServiceName IN ('api')" },
3858+
],
3859+
},
3860+
);
3861+
expect(patchResult.isError).toBeFalsy();
3862+
const patched = JSON.parse(getFirstText(patchResult));
3863+
expect(patched.savedFilterValues).toEqual([
3864+
{ type: 'sql', condition: "ServiceName IN ('api')" },
3865+
]);
3866+
3867+
const stored = await Dashboard.findById(created.id);
3868+
expect(stored?.savedFilterValues).toEqual([
3869+
{ type: 'sql', condition: "ServiceName IN ('api')" },
3870+
]);
3871+
3872+
// Tiles are preserved by the targeted patch.
3873+
expect(stored?.tiles).toHaveLength(1);
3874+
});
3875+
3876+
it('patches a tile and savedFilterValues in the same call', async () => {
3877+
const sourceId = ctx.traceSource._id.toString();
3878+
3879+
const createResult = await callTool(
3880+
ctx.client!,
3881+
'clickstack_save_dashboard',
3882+
{ name: 'Combined patch', tiles: [tile(sourceId)] },
3883+
);
3884+
const created = JSON.parse(getFirstText(createResult));
3885+
const tileId = created.tiles[0].id;
3886+
3887+
const patchResult = await callTool(
3888+
ctx.client!,
3889+
'clickstack_patch_dashboard',
3890+
{
3891+
dashboardId: created.id,
3892+
savedFilterValues: [
3893+
{ type: 'sql', condition: "ServiceName IN ('api')" },
3894+
],
3895+
tileId,
3896+
tile: {
3897+
config: {
3898+
displayType: 'table',
3899+
sourceId,
3900+
select: [{ aggFn: 'count', where: '' }],
3901+
},
3902+
},
3903+
},
3904+
);
3905+
expect(patchResult.isError).toBeFalsy();
3906+
const patched = JSON.parse(getFirstText(patchResult));
3907+
expect(patched.savedFilterValues).toEqual([
3908+
{ type: 'sql', condition: "ServiceName IN ('api')" },
3909+
]);
3910+
expect(patched.patchedTile.config.displayType).toBe('table');
3911+
3912+
// Both the tile and the saved values changed in the database.
3913+
const stored = await Dashboard.findById(created.id);
3914+
expect(stored?.tiles).toHaveLength(1);
3915+
expect(stored?.tiles[0].id).toBe(tileId);
3916+
expect(stored?.tiles[0].config.displayType).toBe('table');
3917+
expect(stored?.savedFilterValues).toEqual([
3918+
{ type: 'sql', condition: "ServiceName IN ('api')" },
3919+
]);
3920+
});
3921+
3922+
it('warns when a variable saved value names no declared filter', async () => {
3923+
const sourceId = ctx.traceSource._id.toString();
3924+
3925+
const result = await callTool(ctx.client!, 'clickstack_save_dashboard', {
3926+
name: 'Variable default typo',
3927+
tiles: [tile(sourceId)],
3928+
filters: [
3929+
{
3930+
type: 'QUERY_EXPRESSION',
3931+
name: 'Service',
3932+
expression: 'ServiceName',
3933+
sourceId,
3934+
whereLanguage: 'sql',
3935+
isVariableEnabled: true,
3936+
variableName: 'service',
3937+
},
3938+
],
3939+
savedFilterValues: [
3940+
{ type: 'variable', name: 'srvice', values: ['api'] },
3941+
],
3942+
});
3943+
3944+
expect(result.isError).toBeFalsy();
3945+
const created = JSON.parse(getFirstText(result));
3946+
expect(created.warnings).toEqual(
3947+
expect.arrayContaining([expect.stringContaining('$srvice')]),
3948+
);
3949+
});
3950+
3951+
it('does not warn when a variable saved value matches a declared filter', async () => {
3952+
const sourceId = ctx.traceSource._id.toString();
3953+
3954+
const result = await callTool(ctx.client!, 'clickstack_save_dashboard', {
3955+
name: 'Variable default match',
3956+
tiles: [tile(sourceId)],
3957+
filters: [
3958+
{
3959+
type: 'QUERY_EXPRESSION',
3960+
name: 'Service',
3961+
expression: 'ServiceName',
3962+
sourceId,
3963+
whereLanguage: 'sql',
3964+
isVariableEnabled: true,
3965+
variableName: 'service',
3966+
},
3967+
],
3968+
savedFilterValues: [
3969+
{ type: 'variable', name: 'service', values: ['api'] },
3970+
],
3971+
});
3972+
3973+
expect(result.isError).toBeFalsy();
3974+
const created = JSON.parse(getFirstText(result));
3975+
const savedValueWarnings = (created.warnings ?? []).filter((w: string) =>
3976+
w.includes('savedFilterValues'),
3977+
);
3978+
expect(savedValueWarnings).toEqual([]);
3979+
});
3980+
3981+
it('warns on a patch whose variable saved value names no declared filter', async () => {
3982+
const sourceId = ctx.traceSource._id.toString();
3983+
3984+
const createResult = await callTool(
3985+
ctx.client!,
3986+
'clickstack_save_dashboard',
3987+
{ name: 'Patch variable typo', tiles: [tile(sourceId)] },
3988+
);
3989+
const created = JSON.parse(getFirstText(createResult));
3990+
3991+
const patchResult = await callTool(
3992+
ctx.client!,
3993+
'clickstack_patch_dashboard',
3994+
{
3995+
dashboardId: created.id,
3996+
savedFilterValues: [
3997+
{ type: 'variable', name: 'ghost', values: ['api'] },
3998+
],
3999+
},
4000+
);
4001+
expect(patchResult.isError).toBeFalsy();
4002+
const patched = JSON.parse(getFirstText(patchResult));
4003+
expect(patched.warnings).toEqual(
4004+
expect.arrayContaining([expect.stringContaining('$ghost')]),
4005+
);
4006+
});
4007+
4008+
it('rejects a patch with no updatable fields', async () => {
4009+
const sourceId = ctx.traceSource._id.toString();
4010+
4011+
const createResult = await callTool(
4012+
ctx.client!,
4013+
'clickstack_save_dashboard',
4014+
{
4015+
name: 'Empty patch',
4016+
tiles: [tile(sourceId)],
4017+
},
4018+
);
4019+
const created = JSON.parse(getFirstText(createResult));
4020+
4021+
const patchResult = await callTool(
4022+
ctx.client!,
4023+
'clickstack_patch_dashboard',
4024+
{ dashboardId: created.id },
4025+
);
4026+
expect(patchResult.isError).toBe(true);
4027+
expect(getFirstText(patchResult)).toContain('savedFilterValues');
4028+
});
4029+
});
36874030
});

‎packages/api/src/mcp/prompts/dashboards/content.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ Apply these before calling clickstack_save_dashboard. Each rule is enforced by t
120120
121121
9f. MAKE A FILTER SINGLE-SELECT WHEN A TILE ONLY MAKES SENSE FOR ONE VALUE. maxSelections: 1 limits the dropdown to one value at a time, for example a host picker feeding a per-host detail view.
122122
123+
9g. SET savedFilterValues TO GIVE THE DASHBOARD A DEFAULT SELECTION. Pass savedFilterValues on clickstack_save_dashboard or clickstack_patch_dashboard to pre-select filter values whenever the dashboard opens. Pair this with a required filter (9e) so the dashboard opens scoped rather than blocked.
124+
123125
10. UPDATE IS REPLACE, NOT MERGE. clickstack_save_dashboard with an id overwrites tiles, containers, and filters in their entirety. Call clickstack_get_dashboard first when you only want to add or rename one entry; do not send a partial set or you will silently drop everything you omitted.
124126
125127
11. GROUP RELATED TILES INTO CONTAINERS. REQUIRED at five or more tiles, no exceptions. An ungrouped wall of nine or ten tiles is a readability failure even when each tile is correct in isolation. Containers are the right way to introduce structure; markdown tiles for section labels are not.

0 commit comments

Comments
 (0)