Skip to content

Commit e494a46

Browse files
feat(mcp): declare all four behavior hints on every tool (#3395)
## Summary MCP compatibility checks fail any tool that does not declare `readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint` as booleans. Each tool set only one hint before this change. - Adds presets in `packages/api/src/mcp/tools/annotations.ts`: - `READ_ONLY_TOOL` for get/list/query tools. - `UPSERT_TOOL` for create-or-update tools. They are not idempotent: omitting `id` creates a new object on every call. - `IDEMPOTENT_DESTRUCTIVE_TOOL` for `delete_*` and `patch_dashboard`. - `openWorldHint` is `false` on every tool, because tools only touch the team's own Mongo metadata and ClickHouse data. - `RegisterToolFn` now requires all four hints, so a new tool without them fails to compile. - Test: `packages/api/src/mcp/__tests__/toolAnnotations.test.ts`. **Downstream (EE) impact:** the EE-only tools `clickstack_get_source_filter_values`, `clickstack_backtest_anomaly_alert`, and `clickstack_list_teams` will fail typecheck on the upstream merge until they get annotations. ### How to reproduce **Starting point:** local full stack (`yarn dev`) with `npx @modelcontextprotocol/inspector` connected to the local MCP endpoint (see `MCP.md`). **On `main`:** 1. Run `initialize`, then `tools/list`. 2. Each tool's `annotations` has only one hint set. **On this branch:** 1. Run `initialize`, then `tools/list`. 2. Every tool's `annotations` has all four hints set as booleans. ### How to test on Vercel preview N/A — non-UI change. ### References - Related PRs: `agent/mcp-tool-descriptions` (independent; touches neighboring lines, but the two 3-way merge cleanly)
1 parent 03f9fd5 commit e494a46

34 files changed

Lines changed: 140 additions & 33 deletions

‎.changeset/mcp-tool-annotations.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hyperdx/api': patch
3+
---
4+
5+
feat(mcp): declare readOnlyHint, destructiveHint, idempotentHint, and openWorldHint on every MCP tool
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { McpContext } from '@/mcp/tools/types';
2+
3+
import { createTestClient } from './mcpTestUtils';
4+
5+
describe('MCP tool annotations', () => {
6+
const context: McpContext = { teamId: 'team-id', userId: 'user-id' };
7+
8+
it('declares every behavior hint as a boolean on every tool', async () => {
9+
const client = await createTestClient(context);
10+
const { tools } = await client.listTools();
11+
expect(tools.length).toBeGreaterThan(0);
12+
13+
const missing = tools.flatMap(tool =>
14+
Object.entries({
15+
readOnlyHint: tool.annotations?.readOnlyHint,
16+
destructiveHint: tool.annotations?.destructiveHint,
17+
idempotentHint: tool.annotations?.idempotentHint,
18+
openWorldHint: tool.annotations?.openWorldHint,
19+
})
20+
.filter(([, value]) => typeof value !== 'boolean')
21+
.map(([hint]) => `${tool.name}.annotations.${hint}`),
22+
);
23+
24+
expect(missing).toEqual([]);
25+
});
26+
});

‎packages/api/src/mcp/tools/alerts/deleteWebhook.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import mongoose from 'mongoose';
22
import { z } from 'zod';
33

44
import { deleteWebhook } from '@/controllers/webhook';
5+
import { IDEMPOTENT_DESTRUCTIVE_TOOL } from '@/mcp/tools/annotations';
56
import type { ToolRegistrar } from '@/mcp/tools/types';
67
import { mcpUserError } from '@/mcp/utils/errors';
78
import { objectIdSchema } from '@/utils/zod';
@@ -16,7 +17,7 @@ export function registerDeleteWebhook({
1617
'clickstack_delete_webhook',
1718
{
1819
title: 'Delete Webhook',
19-
annotations: { destructiveHint: true },
20+
annotations: IDEMPOTENT_DESTRUCTIVE_TOOL,
2021
description:
2122
'Permanently delete a webhook by ID. Blocked while any alert still ' +
2223
'references it — reassign or delete those alerts first. Use ' +

‎packages/api/src/mcp/tools/alerts/getAlert.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { z } from 'zod';
66
import * as config from '@/config';
77
import { getRecentAlertHistories } from '@/controllers/alertHistory';
88
import { getAlertById } from '@/controllers/alerts';
9+
import { READ_ONLY_TOOL } from '@/mcp/tools/annotations';
910
import type { ToolRegistrar } from '@/mcp/tools/types';
1011
import { mcpUserError, validateObjectId } from '@/mcp/utils/errors';
1112
import Alert from '@/models/alert';
@@ -25,7 +26,7 @@ export function registerGetAlert({
2526
'clickstack_get_alert',
2627
{
2728
title: 'Get Alert(s)',
28-
annotations: { readOnlyHint: true },
29+
annotations: READ_ONLY_TOOL,
2930
description:
3031
'Without an ID: list all alerts as a high-level summary ' +
3132
'(id, name, displayName, tags, state, source, interval). Optionally ' +

‎packages/api/src/mcp/tools/alerts/getWebhook.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z } from 'zod';
22

3+
import { READ_ONLY_TOOL } from '@/mcp/tools/annotations';
34
import type { ToolRegistrar } from '@/mcp/tools/types';
45
import Webhook from '@/models/webhook';
56

@@ -13,7 +14,7 @@ export function registerGetWebhook({
1314
'clickstack_get_webhook',
1415
{
1516
title: 'List Webhooks',
16-
annotations: { readOnlyHint: true },
17+
annotations: READ_ONLY_TOOL,
1718
description:
1819
'List available webhook destinations (id, name, service type). ' +
1920
'Use the returned id as the webhookId when creating alerts with ' +

‎packages/api/src/mcp/tools/alerts/saveAlert.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
updateAlert,
99
validateAlertInput,
1010
} from '@/controllers/alerts';
11+
import { UPSERT_TOOL } from '@/mcp/tools/annotations';
1112
import type { ToolRegistrar } from '@/mcp/tools/types';
1213
import {
1314
formatZodIssues,
@@ -42,7 +43,7 @@ export function registerSaveAlert({
4243
'clickstack_save_alert',
4344
{
4445
title: 'Create or Update Alert',
45-
annotations: { destructiveHint: true },
46+
annotations: UPSERT_TOOL,
4647
description:
4748
'Create a new alert (omit id) or update an existing one (provide id). ' +
4849
'Alerts monitor a saved search, a dashboard tile, or an inline chart ' +

‎packages/api/src/mcp/tools/alerts/saveWebhook.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import mongoose from 'mongoose';
33
import { z } from 'zod';
44

55
import { createWebhook, updateWebhook } from '@/controllers/webhook';
6+
import { UPSERT_TOOL } from '@/mcp/tools/annotations';
67
import type { ToolRegistrar } from '@/mcp/tools/types';
78
import {
89
mcpServerError,
@@ -97,7 +98,7 @@ export function registerSaveWebhook({
9798
'clickstack_save_webhook',
9899
{
99100
title: 'Create or Update Webhook',
100-
annotations: { destructiveHint: true },
101+
annotations: UPSERT_TOOL,
101102
description:
102103
'Create a new webhook (omit id) or update an existing one (provide ' +
103104
'id). Use the returned id as the webhookId when creating alerts with ' +
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
2+
3+
/**
4+
* The four behavior hints every tool must declare. Platform compatibility
5+
* checks reject tools that leave any of them unset.
6+
*/
7+
export type ToolBehaviorAnnotations = Required<
8+
Pick<
9+
ToolAnnotations,
10+
'readOnlyHint' | 'destructiveHint' | 'idempotentHint' | 'openWorldHint'
11+
>
12+
>;
13+
14+
// Every tool reads or writes only the calling team's own ClickStack metadata
15+
// (MongoDB) and telemetry (ClickHouse), so none of them is open-world.
16+
17+
export const READ_ONLY_TOOL: ToolBehaviorAnnotations = {
18+
readOnlyHint: true,
19+
destructiveHint: false,
20+
idempotentHint: true,
21+
openWorldHint: false,
22+
};
23+
24+
/**
25+
* Create-or-update tools: omitting `id` creates a new object on every call
26+
* (not idempotent), and providing `id` overwrites the stored object.
27+
*/
28+
export const UPSERT_TOOL: ToolBehaviorAnnotations = {
29+
readOnlyHint: false,
30+
destructiveHint: true,
31+
idempotentHint: false,
32+
openWorldHint: false,
33+
};
34+
35+
/**
36+
* Updates or deletes an existing object by ID. Repeating the call with the
37+
* same arguments leaves the same end state.
38+
*/
39+
export const IDEMPOTENT_DESTRUCTIVE_TOOL: ToolBehaviorAnnotations = {
40+
readOnlyHint: false,
41+
destructiveHint: true,
42+
idempotentHint: true,
43+
openWorldHint: false,
44+
};

‎packages/api/src/mcp/tools/dashboards/deleteDashboard.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import mongoose from 'mongoose';
22
import { z } from 'zod';
33

44
import { deleteDashboard } from '@/controllers/dashboard';
5+
import { IDEMPOTENT_DESTRUCTIVE_TOOL } from '@/mcp/tools/annotations';
56
import type { ToolRegistrar } from '@/mcp/tools/types';
67
import { mcpUserError } from '@/mcp/utils/errors';
78
import Dashboard from '@/models/dashboard';
@@ -17,7 +18,7 @@ export function registerDeleteDashboard({
1718
'clickstack_delete_dashboard',
1819
{
1920
title: 'Delete Dashboard',
20-
annotations: { destructiveHint: true },
21+
annotations: IDEMPOTENT_DESTRUCTIVE_TOOL,
2122
description:
2223
'Permanently delete a dashboard by ID. Also removes any alerts attached to its tiles. ' +
2324
'Use clickstack_get_dashboard (without an ID) to list available dashboard IDs.',

‎packages/api/src/mcp/tools/dashboards/getDashboard.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { z } from 'zod';
33

44
import * as config from '@/config';
55
import { getDashboards } from '@/controllers/dashboard';
6+
import { READ_ONLY_TOOL } from '@/mcp/tools/annotations';
67
import type { ToolRegistrar } from '@/mcp/tools/types';
78
import { mcpUserError, validateObjectId } from '@/mcp/utils/errors';
89
import Dashboard from '@/models/dashboard';
@@ -21,7 +22,7 @@ export function registerGetDashboard({
2122
'clickstack_get_dashboard',
2223
{
2324
title: 'Get Dashboard(s)',
24-
annotations: { readOnlyHint: true },
25+
annotations: READ_ONLY_TOOL,
2526
description:
2627
'Without an ID: list all dashboards (returns IDs, names, tags). ' +
2728
'With an ID: get full dashboard detail including all tiles and configuration.',

0 commit comments

Comments
 (0)