From ac9e256cc8561cfa3a54a10f2cfa18b3de356611 Mon Sep 17 00:00:00 2001 From: Brandon Pereira Date: Wed, 13 May 2026 16:39:46 -0600 Subject: [PATCH 1/8] feat(mcp): add alert, saved search, and webhook MCP tools Add five new MCP tools for managing alerts, saved searches, and webhooks: - hyperdx_get_alert: list/detail alerts with state filtering and history - hyperdx_save_alert: create/update alerts with threshold, channel, and schedule config - hyperdx_get_webhook: list webhook destinations for alert channels - hyperdx_get_saved_search: list/detail saved searches - hyperdx_save_saved_search: create/update saved searches with filters and tags Also makes McpContext.userId required (rejects requests without it), enhances getLoggedInAgent fixture for multi-tenancy test isolation, and adds comprehensive integration tests for all new tools. --- MCP.md | 21 +- packages/api/src/fixtures.ts | 12 +- packages/api/src/mcp/__tests__/alerts.test.ts | 632 ++++++++++++++++++ .../api/src/mcp/__tests__/dashboards.test.ts | 1 + .../src/mcp/__tests__/savedSearches.test.ts | 356 ++++++++++ .../api/src/mcp/__tests__/tracing.test.ts | 15 - packages/api/src/mcp/app.ts | 8 +- packages/api/src/mcp/mcpServer.ts | 4 + packages/api/src/mcp/tools/alerts/getAlert.ts | 112 ++++ .../api/src/mcp/tools/alerts/getWebhook.ts | 41 ++ packages/api/src/mcp/tools/alerts/index.ts | 17 + .../api/src/mcp/tools/alerts/saveAlert.ts | 171 +++++ packages/api/src/mcp/tools/alerts/schemas.ts | 171 +++++ .../mcp/tools/savedSearches/getSavedSearch.ts | 88 +++ .../api/src/mcp/tools/savedSearches/index.ts | 15 + .../tools/savedSearches/saveSavedSearch.ts | 146 ++++ .../src/mcp/tools/savedSearches/schemas.ts | 75 +++ packages/api/src/mcp/tools/types.ts | 2 +- packages/api/src/mcp/utils/tracing.ts | 4 +- 19 files changed, 1859 insertions(+), 32 deletions(-) create mode 100644 packages/api/src/mcp/__tests__/alerts.test.ts create mode 100644 packages/api/src/mcp/__tests__/savedSearches.test.ts create mode 100644 packages/api/src/mcp/tools/alerts/getAlert.ts create mode 100644 packages/api/src/mcp/tools/alerts/getWebhook.ts create mode 100644 packages/api/src/mcp/tools/alerts/index.ts create mode 100644 packages/api/src/mcp/tools/alerts/saveAlert.ts create mode 100644 packages/api/src/mcp/tools/alerts/schemas.ts create mode 100644 packages/api/src/mcp/tools/savedSearches/getSavedSearch.ts create mode 100644 packages/api/src/mcp/tools/savedSearches/index.ts create mode 100644 packages/api/src/mcp/tools/savedSearches/saveSavedSearch.ts create mode 100644 packages/api/src/mcp/tools/savedSearches/schemas.ts diff --git a/MCP.md b/MCP.md index 542b85840d..fc3c62151f 100644 --- a/MCP.md +++ b/MCP.md @@ -106,11 +106,16 @@ with: ## Available Tools -| Tool | Description | -| -------------------------- | -------------------------------------------------------------------------------------------- | -| `hyperdx_list_sources` | List all data sources and database connections, including column schemas and attribute keys | -| `hyperdx_query` | Query observability data (logs, metrics, traces) using builder mode, search mode, or raw SQL | -| `hyperdx_get_dashboard` | List all dashboards or get full detail for a specific dashboard | -| `hyperdx_save_dashboard` | Create or update a dashboard with tiles (charts, tables, numbers, search, markdown) | -| `hyperdx_delete_dashboard` | Permanently delete a dashboard and its attached alerts | -| `hyperdx_query_tile` | Execute the query for a specific dashboard tile to validate results | +| Tool | Description | +| ----------------------------- | -------------------------------------------------------------------------------------------- | +| `hyperdx_list_sources` | List all data sources and database connections, including column schemas and attribute keys | +| `hyperdx_query` | Query observability data (logs, metrics, traces) using builder mode, search mode, or raw SQL | +| `hyperdx_get_dashboard` | List all dashboards or get full detail for a specific dashboard | +| `hyperdx_save_dashboard` | Create or update a dashboard with tiles (charts, tables, numbers, search, markdown) | +| `hyperdx_delete_dashboard` | Permanently delete a dashboard and its attached alerts | +| `hyperdx_query_tile` | Execute the query for a specific dashboard tile to validate results | +| `hyperdx_get_saved_search` | List all saved searches or get full detail for a specific saved search | +| `hyperdx_save_saved_search` | Create or update a saved search (reusable query against a data source) | +| `hyperdx_get_alert` | List alerts (summary) or get full detail with evaluation history; filter by state | +| `hyperdx_save_alert` | Create a new alert or update an existing one | +| `hyperdx_get_webhook` | List available webhook destinations for use as alert notification channels | diff --git a/packages/api/src/fixtures.ts b/packages/api/src/fixtures.ts index 3345b0cf25..5dc05094ac 100644 --- a/packages/api/src/fixtures.ts +++ b/packages/api/src/fixtures.ts @@ -214,15 +214,19 @@ export const getServer = () => new MockServer(); export const getAgent = (server: MockServer) => request.agent(server.getHttpServer()); -export const getLoggedInAgent = async (server: MockServer) => { +export const getLoggedInAgent = async ( + server: MockServer, + credentials?: { email: string; password: string }, +) => { const agent = getAgent(server); + const creds = credentials ?? MOCK_USER; await agent .post('/register/password') - .send({ ...MOCK_USER, confirmPassword: MOCK_USER.password }) + .send({ ...creds, confirmPassword: creds.password }) .expect(200); - const user = await findUserByEmail(MOCK_USER.email); + const user = await findUserByEmail(creds.email); const team = await getTeam(user?.team as any); if (team === null || user === null) { @@ -231,7 +235,7 @@ export const getLoggedInAgent = async (server: MockServer) => { // login app — 303 See Other so the browser follows the redirect with GET // (see redirectToDashboard in middleware/auth.ts). - await agent.post('/login/password').send(MOCK_USER).expect(303); + await agent.post('/login/password').send(creds).expect(303); return { agent, diff --git a/packages/api/src/mcp/__tests__/alerts.test.ts b/packages/api/src/mcp/__tests__/alerts.test.ts new file mode 100644 index 0000000000..f8821c9b13 --- /dev/null +++ b/packages/api/src/mcp/__tests__/alerts.test.ts @@ -0,0 +1,632 @@ +import { SourceKind } from '@hyperdx/common-utils/dist/types'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; + +import * as config from '@/config'; +import { + DEFAULT_DATABASE, + DEFAULT_TRACES_TABLE, + getLoggedInAgent, + getServer, +} from '@/fixtures'; +import Alert, { AlertState } from '@/models/alert'; +import Connection from '@/models/connection'; +import Dashboard from '@/models/dashboard'; +import { SavedSearch } from '@/models/savedSearch'; +import { Source } from '@/models/source'; +import Webhook, { WebhookService } from '@/models/webhook'; + +import { McpContext } from '../tools/types'; +import { callTool, createTestClient, getFirstText } from './mcpTestUtils'; + +describe('MCP Alert Tools', () => { + const server = getServer(); + let team: any; + let user: any; + let connection: any; + let traceSource: any; + let client: Client; + + beforeAll(async () => { + await server.start(); + }); + + beforeEach(async () => { + const result = await getLoggedInAgent(server); + team = result.team; + user = result.user; + + connection = await Connection.create({ + team: team._id, + name: 'Default', + host: config.CLICKHOUSE_HOST, + username: config.CLICKHOUSE_USER, + password: config.CLICKHOUSE_PASSWORD, + }); + + traceSource = await Source.create({ + kind: SourceKind.Trace, + team: team._id, + from: { + databaseName: DEFAULT_DATABASE, + tableName: DEFAULT_TRACES_TABLE, + }, + timestampValueExpression: 'Timestamp', + connection: connection._id, + name: 'Traces', + }); + + const context: McpContext = { + teamId: team._id.toString(), + userId: user._id.toString(), + }; + client = await createTestClient(context); + }); + + afterEach(async () => { + await client.close(); + await server.clearDBs(); + }); + + afterAll(async () => { + await server.stop(); + }); + + // ─── helpers ────────────────────────────────────────────────────────────── + + async function createTestSavedSearch() { + return SavedSearch.create({ + team: team._id, + name: 'Test Saved Search', + source: traceSource._id, + }); + } + + async function createTestDashboardWithTile() { + return new Dashboard({ + name: 'Test Dashboard', + team: team._id, + tiles: [ + { + id: 'tile-1', + config: { + name: 'Error Count', + displayType: 'number', + source: traceSource._id.toString(), + series: [{ type: 'time', aggFn: 'count' }], + }, + }, + ], + }).save(); + } + + async function createTestWebhook() { + return Webhook.create({ + team: team._id, + name: 'Test Webhook', + service: WebhookService.Generic, + url: 'https://example.com/webhook', + }); + } + + async function createTestAlert(overrides: Record = {}) { + const savedSearch = await createTestSavedSearch(); + return new Alert({ + team: team._id, + source: 'saved_search', + savedSearch: savedSearch._id, + threshold: 100, + thresholdType: 'above', + interval: '5m', + channel: { type: 'webhook', webhookId: 'fake-webhook-id' }, + state: AlertState.OK, + createdBy: user._id, + ...overrides, + }).save(); + } + + // ─── hyperdx_get_alert ──────────────────────────────────────────────────── + + describe('hyperdx_get_alert', () => { + describe('list (no id)', () => { + it('should list all alerts with slim summary fields', async () => { + await createTestAlert({ name: 'Alert 1' }); + await createTestAlert({ name: 'Alert 2' }); + + const result = await callTool(client, 'hyperdx_get_alert', {}); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output).toHaveLength(2); + + // Slim summary fields present + expect(output[0]).toHaveProperty('id'); + expect(output[0]).toHaveProperty('name'); + expect(output[0]).toHaveProperty('state'); + expect(output[0]).toHaveProperty('source'); + expect(output[0]).toHaveProperty('interval'); + + // Full detail fields should NOT be present in list mode + expect(output[0]).not.toHaveProperty('threshold'); + expect(output[0]).not.toHaveProperty('channel'); + expect(output[0]).not.toHaveProperty('history'); + expect(output[0]).not.toHaveProperty('teamId'); + }); + + it('should return empty array when no alerts exist', async () => { + const result = await callTool(client, 'hyperdx_get_alert', {}); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output).toHaveLength(0); + }); + + it('should filter by state when provided', async () => { + await createTestAlert({ name: 'Firing', state: AlertState.ALERT }); + await createTestAlert({ name: 'OK', state: AlertState.OK }); + await createTestAlert({ name: 'Disabled', state: AlertState.DISABLED }); + + const result = await callTool(client, 'hyperdx_get_alert', { + state: 'ALERT', + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output).toHaveLength(1); + expect(output[0].name).toBe('Firing'); + expect(output[0].state).toBe('ALERT'); + }); + + it('should return empty array when no alerts match state filter', async () => { + await createTestAlert({ name: 'All Good', state: AlertState.OK }); + + const result = await callTool(client, 'hyperdx_get_alert', { + state: 'ALERT', + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output).toHaveLength(0); + }); + + it('should not return alerts from another team', async () => { + await createTestAlert({ name: 'Team Scoped' }); + + // Create a new team/user with its own client + const result2 = await getLoggedInAgent(server, { + email: 'other-team-user@test.com', + password: 'TacoCat!2#4X', + }); + const context2: McpContext = { + teamId: result2.team._id.toString(), + userId: result2.user._id.toString(), + }; + const client2 = await createTestClient(context2); + + // List should be empty for the other team + const listResult = await callTool(client2, 'hyperdx_get_alert', {}); + const output = JSON.parse(getFirstText(listResult)); + expect(output).toHaveLength(0); + + await client2.close(); + }); + }); + + describe('detail (with id)', () => { + it('should get full alert detail with history when valid id is provided', async () => { + const alert = await createTestAlert({ name: 'Detail Test' }); + + const result = await callTool(client, 'hyperdx_get_alert', { + id: alert._id.toString(), + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.id).toBe(alert._id.toString()); + expect(output.name).toBe('Detail Test'); + expect(output).toHaveProperty('history'); + expect(Array.isArray(output.history)).toBe(true); + // Full detail includes fields not in the list summary + expect(output).toHaveProperty('threshold'); + expect(output).toHaveProperty('channel'); + expect(output).toHaveProperty('teamId'); + }); + + it('should return error for invalid ObjectId format', async () => { + const result = await callTool(client, 'hyperdx_get_alert', { + id: 'not-a-valid-id', + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('Invalid alert ID'); + }); + + it('should return error for non-existent alert id', async () => { + const fakeId = '000000000000000000000000'; + const result = await callTool(client, 'hyperdx_get_alert', { + id: fakeId, + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('not found'); + }); + + it('should not return alert from another team by id', async () => { + const alert = await createTestAlert({ name: 'Team Scoped' }); + + const result2 = await getLoggedInAgent(server, { + email: 'other-team-user@test.com', + password: 'TacoCat!2#4X', + }); + const context2: McpContext = { + teamId: result2.team._id.toString(), + userId: result2.user._id.toString(), + }; + const client2 = await createTestClient(context2); + + const getResult = await callTool(client2, 'hyperdx_get_alert', { + id: alert._id.toString(), + }); + expect(getResult.isError).toBe(true); + expect(getFirstText(getResult)).toContain('not found'); + + await client2.close(); + }); + }); + }); + + // ─── hyperdx_save_alert ─────────────────────────────────────────────────── + + describe('hyperdx_save_alert', () => { + describe('create', () => { + it('should create a saved-search alert', async () => { + const savedSearch = await createTestSavedSearch(); + const webhook = await createTestWebhook(); + + const result = await callTool(client, 'hyperdx_save_alert', { + source: 'saved_search', + savedSearchId: savedSearch._id.toString(), + threshold: 50, + thresholdType: 'above', + interval: '5m', + channel: { + type: 'webhook', + webhookId: webhook._id.toString(), + }, + name: 'MCP Created Alert', + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.id).toBeDefined(); + expect(output.name).toBe('MCP Created Alert'); + expect(output.source).toBe('saved_search'); + expect(output.threshold).toBe(50); + expect(output.state).toBe('OK'); + }); + + it('should create a tile-based alert', async () => { + const dashboard = await createTestDashboardWithTile(); + const webhook = await createTestWebhook(); + + const result = await callTool(client, 'hyperdx_save_alert', { + source: 'tile', + dashboardId: dashboard._id.toString(), + tileId: 'tile-1', + threshold: 200, + thresholdType: 'above', + interval: '1h', + channel: { + type: 'webhook', + webhookId: webhook._id.toString(), + }, + name: 'Tile Alert', + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.id).toBeDefined(); + expect(output.source).toBe('tile'); + expect(output.dashboardId).toBe(dashboard._id.toString()); + expect(output.tileId).toBe('tile-1'); + }); + + it('should reject tile source without dashboardId', async () => { + const webhook = await createTestWebhook(); + + const result = await callTool(client, 'hyperdx_save_alert', { + source: 'tile', + tileId: 'tile-1', + threshold: 100, + thresholdType: 'above', + interval: '5m', + channel: { + type: 'webhook', + webhookId: webhook._id.toString(), + }, + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('dashboardId is required'); + }); + + it('should reject tile source without tileId', async () => { + const dashboard = await createTestDashboardWithTile(); + const webhook = await createTestWebhook(); + + const result = await callTool(client, 'hyperdx_save_alert', { + source: 'tile', + dashboardId: dashboard._id.toString(), + threshold: 100, + thresholdType: 'above', + interval: '5m', + channel: { + type: 'webhook', + webhookId: webhook._id.toString(), + }, + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('tileId is required'); + }); + + it('should reject saved_search source without savedSearchId', async () => { + const webhook = await createTestWebhook(); + + const result = await callTool(client, 'hyperdx_save_alert', { + source: 'saved_search', + threshold: 100, + thresholdType: 'above', + interval: '5m', + channel: { + type: 'webhook', + webhookId: webhook._id.toString(), + }, + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('savedSearchId is required'); + }); + + it('should reject non-existent saved search', async () => { + const webhook = await createTestWebhook(); + const fakeId = '000000000000000000000000'; + + const result = await callTool(client, 'hyperdx_save_alert', { + source: 'saved_search', + savedSearchId: fakeId, + threshold: 100, + thresholdType: 'above', + interval: '5m', + channel: { + type: 'webhook', + webhookId: webhook._id.toString(), + }, + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('Saved search not found'); + }); + + it('should reject webhook channel without webhookId', async () => { + const savedSearch = await createTestSavedSearch(); + + const result = await callTool(client, 'hyperdx_save_alert', { + source: 'saved_search', + savedSearchId: savedSearch._id.toString(), + threshold: 100, + thresholdType: 'above', + interval: '5m', + channel: { + type: 'webhook', + }, + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('webhookId'); + }); + + it('should reject between thresholdType without thresholdMax', async () => { + const savedSearch = await createTestSavedSearch(); + const webhook = await createTestWebhook(); + + const result = await callTool(client, 'hyperdx_save_alert', { + source: 'saved_search', + savedSearchId: savedSearch._id.toString(), + threshold: 100, + thresholdType: 'between', + interval: '5m', + channel: { + type: 'webhook', + webhookId: webhook._id.toString(), + }, + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('thresholdMax is required'); + }); + + it('should return created alert in external format', async () => { + const savedSearch = await createTestSavedSearch(); + const webhook = await createTestWebhook(); + + const result = await callTool(client, 'hyperdx_save_alert', { + source: 'saved_search', + savedSearchId: savedSearch._id.toString(), + threshold: 75, + thresholdType: 'below', + interval: '15m', + channel: { + type: 'webhook', + webhookId: webhook._id.toString(), + }, + name: 'External Format Test', + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + // External format uses 'id' not '_id' + expect(output).toHaveProperty('id'); + expect(output).not.toHaveProperty('_id'); + expect(output).toHaveProperty('teamId'); + expect(output).toHaveProperty('createdAt'); + }); + }); + + describe('update', () => { + it('should update an existing alert', async () => { + const savedSearch = await createTestSavedSearch(); + const webhook = await createTestWebhook(); + + // Create the alert first + const alert = await new Alert({ + team: team._id, + source: 'saved_search', + savedSearch: savedSearch._id, + threshold: 100, + thresholdType: 'above', + interval: '5m', + channel: { + type: 'webhook', + webhookId: webhook._id.toString(), + }, + state: AlertState.OK, + createdBy: user._id, + name: 'Original Name', + }).save(); + + const result = await callTool(client, 'hyperdx_save_alert', { + id: alert._id.toString(), + source: 'saved_search', + savedSearchId: savedSearch._id.toString(), + threshold: 200, + thresholdType: 'above', + interval: '15m', + channel: { + type: 'webhook', + webhookId: webhook._id.toString(), + }, + name: 'Updated Name', + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.id).toBe(alert._id.toString()); + expect(output.name).toBe('Updated Name'); + expect(output.threshold).toBe(200); + expect(output.interval).toBe('15m'); + }); + + it('should return not found for non-existent alert id', async () => { + const savedSearch = await createTestSavedSearch(); + const webhook = await createTestWebhook(); + const fakeId = '000000000000000000000000'; + + const result = await callTool(client, 'hyperdx_save_alert', { + id: fakeId, + source: 'saved_search', + savedSearchId: savedSearch._id.toString(), + threshold: 100, + thresholdType: 'above', + interval: '5m', + channel: { + type: 'webhook', + webhookId: webhook._id.toString(), + }, + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('not found'); + }); + + it('should return error for invalid ObjectId format on update', async () => { + const savedSearch = await createTestSavedSearch(); + const webhook = await createTestWebhook(); + + const result = await callTool(client, 'hyperdx_save_alert', { + id: '!!!', + source: 'saved_search', + savedSearchId: savedSearch._id.toString(), + threshold: 100, + thresholdType: 'above', + interval: '5m', + channel: { + type: 'webhook', + webhookId: webhook._id.toString(), + }, + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('Invalid alert ID'); + }); + }); + }); + + // ─── hyperdx_get_webhook ────────────────────────────────────────────────── + + describe('hyperdx_get_webhook', () => { + it('should list all webhooks with slim fields', async () => { + await Webhook.create({ + team: team._id, + name: 'Generic Hook', + service: WebhookService.Generic, + url: 'https://example.com/hook1', + }); + await Webhook.create({ + team: team._id, + name: 'Incident Hook', + service: WebhookService.IncidentIO, + url: 'https://example.com/hook2', + }); + + const result = await callTool(client, 'hyperdx_get_webhook', {}); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output).toHaveLength(2); + + // Slim fields present + expect(output[0]).toHaveProperty('id'); + expect(output[0]).toHaveProperty('name'); + expect(output[0]).toHaveProperty('service'); + + // Sensitive/detail fields should NOT be present + expect(output[0]).not.toHaveProperty('url'); + expect(output[0]).not.toHaveProperty('headers'); + expect(output[0]).not.toHaveProperty('queryParams'); + expect(output[0]).not.toHaveProperty('body'); + }); + + it('should return empty array when no webhooks exist', async () => { + const result = await callTool(client, 'hyperdx_get_webhook', {}); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output).toHaveLength(0); + }); + + it('should scope webhooks to the team', async () => { + await Webhook.create({ + team: team._id, + name: 'Team Webhook', + service: WebhookService.Generic, + url: 'https://example.com/hook', + }); + + const result2 = await getLoggedInAgent(server, { + email: 'other-team-user@test.com', + password: 'TacoCat!2#4X', + }); + const context2: McpContext = { + teamId: result2.team._id.toString(), + userId: result2.user._id.toString(), + }; + const client2 = await createTestClient(context2); + + const listResult = await callTool(client2, 'hyperdx_get_webhook', {}); + const output = JSON.parse(getFirstText(listResult)); + expect(output).toHaveLength(0); + + await client2.close(); + }); + }); +}); diff --git a/packages/api/src/mcp/__tests__/dashboards.test.ts b/packages/api/src/mcp/__tests__/dashboards.test.ts index 851b67f65e..90a34292f8 100644 --- a/packages/api/src/mcp/__tests__/dashboards.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards.test.ts @@ -115,6 +115,7 @@ describe('MCP Dashboard Tools', () => { const result2 = await getLoggedInAgent(server); const context2: McpContext = { teamId: result2.team._id.toString(), + userId: result2.user._id.toString(), }; const client2 = await createTestClient(context2); diff --git a/packages/api/src/mcp/__tests__/savedSearches.test.ts b/packages/api/src/mcp/__tests__/savedSearches.test.ts new file mode 100644 index 0000000000..cb1378013b --- /dev/null +++ b/packages/api/src/mcp/__tests__/savedSearches.test.ts @@ -0,0 +1,356 @@ +import { SourceKind } from '@hyperdx/common-utils/dist/types'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; + +import * as config from '@/config'; +import { + DEFAULT_DATABASE, + DEFAULT_TRACES_TABLE, + getLoggedInAgent, + getServer, +} from '@/fixtures'; +import Connection from '@/models/connection'; +import { SavedSearch } from '@/models/savedSearch'; +import { Source } from '@/models/source'; + +import { McpContext } from '../tools/types'; +import { callTool, createTestClient, getFirstText } from './mcpTestUtils'; + +describe('MCP Saved Search Tools', () => { + const server = getServer(); + let team: any; + let user: any; + let connection: any; + let traceSource: any; + let client: Client; + + beforeAll(async () => { + await server.start(); + }); + + beforeEach(async () => { + const result = await getLoggedInAgent(server); + team = result.team; + user = result.user; + + connection = await Connection.create({ + team: team._id, + name: 'Default', + host: config.CLICKHOUSE_HOST, + username: config.CLICKHOUSE_USER, + password: config.CLICKHOUSE_PASSWORD, + }); + + traceSource = await Source.create({ + kind: SourceKind.Trace, + team: team._id, + from: { + databaseName: DEFAULT_DATABASE, + tableName: DEFAULT_TRACES_TABLE, + }, + timestampValueExpression: 'Timestamp', + connection: connection._id, + name: 'Traces', + }); + + const context: McpContext = { + teamId: team._id.toString(), + userId: user._id.toString(), + }; + client = await createTestClient(context); + }); + + afterEach(async () => { + await client.close(); + await server.clearDBs(); + }); + + afterAll(async () => { + await server.stop(); + }); + + // ─── helpers ────────────────────────────────────────────────────────────── + + async function createTestSavedSearch( + overrides: Record = {}, + ) { + return SavedSearch.create({ + team: team._id, + name: 'Test Saved Search', + source: traceSource._id, + select: '', + where: 'StatusCode:Error', + whereLanguage: 'lucene', + tags: ['test'], + createdBy: user._id, + updatedBy: user._id, + ...overrides, + }); + } + + // ─── hyperdx_get_saved_search ───────────────────────────────────────────── + + describe('hyperdx_get_saved_search', () => { + describe('list (no id)', () => { + it('should list all saved searches with slim summary fields', async () => { + await createTestSavedSearch({ name: 'Search 1' }); + await createTestSavedSearch({ name: 'Search 2' }); + + const result = await callTool(client, 'hyperdx_get_saved_search', {}); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output).toHaveLength(2); + + // Slim summary fields present + expect(output[0]).toHaveProperty('id'); + expect(output[0]).toHaveProperty('name'); + expect(output[0]).toHaveProperty('tags'); + + // Detail fields should NOT be present in list mode + expect(output[0]).not.toHaveProperty('where'); + expect(output[0]).not.toHaveProperty('whereLanguage'); + expect(output[0]).not.toHaveProperty('source'); + expect(output[0]).not.toHaveProperty('select'); + expect(output[0]).not.toHaveProperty('filters'); + }); + + it('should return empty array when no saved searches exist', async () => { + const result = await callTool(client, 'hyperdx_get_saved_search', {}); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output).toHaveLength(0); + }); + + it('should scope saved searches to the team', async () => { + await createTestSavedSearch({ name: 'Team Scoped' }); + + const otherTeamContext: McpContext = { + teamId: '000000000000000000000099', + userId: user._id.toString(), + }; + const client2 = await createTestClient(otherTeamContext); + + const listResult = await callTool( + client2, + 'hyperdx_get_saved_search', + {}, + ); + const output = JSON.parse(getFirstText(listResult)); + expect(output).toHaveLength(0); + + await client2.close(); + }); + }); + + describe('detail (with id)', () => { + it('should get full saved search detail when id is provided', async () => { + const savedSearch = await createTestSavedSearch({ + name: 'Detail Test', + where: 'level:error', + }); + + const result = await callTool(client, 'hyperdx_get_saved_search', { + id: savedSearch._id.toString(), + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output._id).toBe(savedSearch._id.toString()); + expect(output.name).toBe('Detail Test'); + expect(output.where).toBe('level:error'); + // Full detail includes fields not in the list summary + expect(output).toHaveProperty('source'); + expect(output).toHaveProperty('whereLanguage'); + expect(output).toHaveProperty('tags'); + }); + + it('should return error for invalid ObjectId format', async () => { + const result = await callTool(client, 'hyperdx_get_saved_search', { + id: 'not-a-valid-id', + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('Invalid saved search ID'); + }); + + it('should return error for non-existent saved search id', async () => { + const fakeId = '000000000000000000000000'; + const result = await callTool(client, 'hyperdx_get_saved_search', { + id: fakeId, + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('Saved search not found'); + }); + }); + }); + + // ─── hyperdx_save_saved_search ──────────────────────────────────────────── + + describe('hyperdx_save_saved_search', () => { + describe('create', () => { + it('should create a new saved search', async () => { + const result = await callTool(client, 'hyperdx_save_saved_search', { + name: 'Error Traces', + sourceId: traceSource._id.toString(), + where: 'StatusCode:Error', + whereLanguage: 'lucene', + tags: ['errors'], + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output._id).toBeDefined(); + expect(output.name).toBe('Error Traces'); + expect(output.where).toBe('StatusCode:Error'); + expect(output.whereLanguage).toBe('lucene'); + expect(output.tags).toEqual(['errors']); + + // Verify in database + const savedSearch = await SavedSearch.findById(output._id); + expect(savedSearch).not.toBeNull(); + expect(savedSearch?.name).toBe('Error Traces'); + }); + + it('should create a saved search with minimal fields', async () => { + const result = await callTool(client, 'hyperdx_save_saved_search', { + name: 'Minimal Search', + sourceId: traceSource._id.toString(), + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.name).toBe('Minimal Search'); + expect(output.where).toBe(''); + expect(output.select).toBe(''); + expect(output.tags).toEqual([]); + }); + + it('should create a saved search with SQL where language', async () => { + const result = await callTool(client, 'hyperdx_save_saved_search', { + name: 'SQL Search', + sourceId: traceSource._id.toString(), + where: "StatusCode = 'Error'", + whereLanguage: 'sql', + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.whereLanguage).toBe('sql'); + expect(output.where).toBe("StatusCode = 'Error'"); + }); + + it('should create a saved search with select and orderBy', async () => { + const result = await callTool(client, 'hyperdx_save_saved_search', { + name: 'Full Search', + sourceId: traceSource._id.toString(), + select: 'body,service.name,duration', + where: 'StatusCode:Error', + orderBy: 'Timestamp DESC', + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.select).toBe('body,service.name,duration'); + expect(output.orderBy).toBe('Timestamp DESC'); + }); + + it('should create a saved search with filters', async () => { + const result = await callTool(client, 'hyperdx_save_saved_search', { + name: 'Filtered Search', + sourceId: traceSource._id.toString(), + filters: [ + { type: 'lucene', condition: 'level:error' }, + { + type: 'sql_ast', + operator: '=', + left: 'StatusCode', + right: 'Error', + }, + ], + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.filters).toHaveLength(2); + expect(output.filters[0].type).toBe('lucene'); + expect(output.filters[1].type).toBe('sql_ast'); + }); + + it('should reject invalid sourceId', async () => { + const result = await callTool(client, 'hyperdx_save_saved_search', { + name: 'Bad Source', + sourceId: 'not-a-valid-id', + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('Invalid sourceId'); + }); + + it('should include url in response when FRONTEND_URL is set', async () => { + const result = await callTool(client, 'hyperdx_save_saved_search', { + name: 'URL Test', + sourceId: traceSource._id.toString(), + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + if (config.FRONTEND_URL) { + expect(output.url).toContain('/search/'); + } + }); + }); + + describe('update', () => { + it('should update an existing saved search', async () => { + const savedSearch = await createTestSavedSearch({ + name: 'Original Name', + }); + + const result = await callTool(client, 'hyperdx_save_saved_search', { + id: savedSearch._id.toString(), + name: 'Updated Name', + sourceId: traceSource._id.toString(), + where: 'StatusCode:Ok', + tags: ['updated'], + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output._id).toBe(savedSearch._id.toString()); + expect(output.name).toBe('Updated Name'); + expect(output.where).toBe('StatusCode:Ok'); + expect(output.tags).toEqual(['updated']); + + // Verify in database + const updated = await SavedSearch.findById(savedSearch._id); + expect(updated?.name).toBe('Updated Name'); + expect(updated?.where).toBe('StatusCode:Ok'); + }); + + it('should return error for non-existent saved search on update', async () => { + const fakeId = '000000000000000000000000'; + const result = await callTool(client, 'hyperdx_save_saved_search', { + id: fakeId, + name: 'Ghost Search', + sourceId: traceSource._id.toString(), + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('Saved search not found'); + }); + + it('should return error for invalid ObjectId format on update', async () => { + const result = await callTool(client, 'hyperdx_save_saved_search', { + id: '!!!', + name: 'Bad ID', + sourceId: traceSource._id.toString(), + }); + + expect(result.isError).toBe(true); + expect(getFirstText(result)).toContain('Invalid saved search ID'); + }); + }); + }); +}); diff --git a/packages/api/src/mcp/__tests__/tracing.test.ts b/packages/api/src/mcp/__tests__/tracing.test.ts index 2fda2191ae..c5615ee4ac 100644 --- a/packages/api/src/mcp/__tests__/tracing.test.ts +++ b/packages/api/src/mcp/__tests__/tracing.test.ts @@ -87,21 +87,6 @@ describe('withToolTracing', () => { ); }); - it('should not set user id attribute when userId is undefined', async () => { - const noUserContext = { teamId: 'team-123' }; - const handler = jest.fn().mockResolvedValue({ - content: [{ type: 'text', text: 'ok' }], - }); - - const traced = withToolTracing('my_tool', noUserContext, handler); - await traced({}); - - expect(mockSpan.setAttribute).not.toHaveBeenCalledWith( - 'mcp.user.id', - expect.anything(), - ); - }); - it('should set OK status for successful results', async () => { const handler = jest.fn().mockResolvedValue({ content: [{ type: 'text', text: 'ok' }], diff --git a/packages/api/src/mcp/app.ts b/packages/api/src/mcp/app.ts index 4eb5d9d5a6..65a2a75dca 100644 --- a/packages/api/src/mcp/app.ts +++ b/packages/api/src/mcp/app.ts @@ -32,6 +32,12 @@ app.all('/', mcpRateLimiter, validateUserAccessKey, async (req, res) => { } const userId = req.user?._id?.toString(); + if (!userId) { + logger.warn('MCP request rejected: no userId'); + res.sendStatus(403); + return; + } + const context: McpContext = { teamId: teamId.toString(), userId, @@ -39,7 +45,7 @@ app.all('/', mcpRateLimiter, validateUserAccessKey, async (req, res) => { setTraceAttributes({ 'mcp.team.id': context.teamId, - ...(userId && { 'mcp.user.id': userId }), + 'mcp.user.id': userId, }); logger.info({ teamId: context.teamId, userId }, 'MCP request received'); diff --git a/packages/api/src/mcp/mcpServer.ts b/packages/api/src/mcp/mcpServer.ts index df9592a1cb..4eabd05938 100644 --- a/packages/api/src/mcp/mcpServer.ts +++ b/packages/api/src/mcp/mcpServer.ts @@ -3,8 +3,10 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { CODE_VERSION } from '@/config'; import dashboardPrompts from './prompts/dashboards/index'; +import alertsTools from './tools/alerts/index'; import dashboardsTools from './tools/dashboards/index'; import queryTools from './tools/query/index'; +import savedSearchesTools from './tools/savedSearches/index'; import { McpContext } from './tools/types'; export function createServer(context: McpContext) { @@ -13,8 +15,10 @@ export function createServer(context: McpContext) { version: `${CODE_VERSION}-beta`, }); + alertsTools(server, context); dashboardsTools(server, context); queryTools(server, context); + savedSearchesTools(server, context); dashboardPrompts(server, context); return server; diff --git a/packages/api/src/mcp/tools/alerts/getAlert.ts b/packages/api/src/mcp/tools/alerts/getAlert.ts new file mode 100644 index 0000000000..e6d2656d45 --- /dev/null +++ b/packages/api/src/mcp/tools/alerts/getAlert.ts @@ -0,0 +1,112 @@ +import { type AlertInterval } from '@hyperdx/common-utils/dist/types'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ObjectId } from 'mongodb'; +import mongoose from 'mongoose'; +import { z } from 'zod'; + +import * as config from '@/config'; +import { getRecentAlertHistories } from '@/controllers/alertHistory'; +import { getAlertById } from '@/controllers/alerts'; +import Alert from '@/models/alert'; +import { translateAlertDocumentToExternalAlert } from '@/utils/externalApi'; + +import { withToolTracing } from '../../utils/tracing'; +import type { McpContext } from '../types'; + +export function registerGetAlert(server: McpServer, context: McpContext): void { + const { teamId } = context; + const frontendUrl = config.FRONTEND_URL; + + server.registerTool( + 'hyperdx_get_alert', + { + title: 'Get Alert(s)', + description: + 'Without an ID: list all alerts as a high-level summary ' + + '(id, name, state, source, interval). Optionally filter by state ' + + '(e.g. state="ALERT" for firing alerts). ' + + 'With an ID: get full alert detail including configuration and ' + + 'recent evaluation history.', + inputSchema: z.object({ + id: z + .string() + .optional() + .describe( + 'Alert ID. Omit to list all alerts, provide to get full detail.', + ), + state: z + .enum(['ALERT', 'OK', 'DISABLED', 'INSUFFICIENT_DATA']) + .optional() + .describe( + 'Filter list by alert state (only applies when id is omitted). ' + + 'Use "ALERT" to find currently firing alerts.', + ), + }), + }, + withToolTracing('hyperdx_get_alert', context, async ({ id, state }) => { + // ── List all alerts (slim summary) ── + if (!id) { + const query: Record = { + team: new mongoose.Types.ObjectId(teamId), + }; + if (state) { + query.state = state; + } + const alerts = await Alert.find(query); + + const output = alerts.map(alert => ({ + id: alert._id.toString(), + name: alert.name, + state: alert.state, + source: alert.source, + interval: alert.interval, + ...(frontendUrl ? { url: `${frontendUrl}/alerts` } : {}), + })); + return { + content: [ + { type: 'text' as const, text: JSON.stringify(output, null, 2) }, + ], + }; + } + + // ── Get single alert (full detail) ── + if (!mongoose.Types.ObjectId.isValid(id)) { + return { + isError: true, + content: [{ type: 'text' as const, text: 'Invalid alert ID' }], + }; + } + + const alert = await getAlertById(id, teamId); + if (!alert) { + return { + isError: true, + content: [{ type: 'text' as const, text: 'Alert not found' }], + }; + } + + const history = await getRecentAlertHistories({ + alertId: new ObjectId(alert._id), + interval: alert.interval as AlertInterval, + limit: 20, + }); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify( + { + ...translateAlertDocumentToExternalAlert(alert), + history, + ...(frontendUrl ? { url: `${frontendUrl}/alerts` } : {}), + }, + null, + 2, + ), + }, + ], + }; + }), + ); +} diff --git a/packages/api/src/mcp/tools/alerts/getWebhook.ts b/packages/api/src/mcp/tools/alerts/getWebhook.ts new file mode 100644 index 0000000000..e87db90fb7 --- /dev/null +++ b/packages/api/src/mcp/tools/alerts/getWebhook.ts @@ -0,0 +1,41 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +import Webhook from '@/models/webhook'; + +import { withToolTracing } from '../../utils/tracing'; +import type { McpContext } from '../types'; + +export function registerGetWebhook( + server: McpServer, + context: McpContext, +): void { + const { teamId } = context; + + server.registerTool( + 'hyperdx_get_webhook', + { + title: 'List Webhooks', + description: + 'List available webhook destinations (id, name, service type). ' + + 'Use the returned id as the webhookId when creating alerts with ' + + 'hyperdx_save_alert.', + inputSchema: z.object({}), + }, + withToolTracing('hyperdx_get_webhook', context, async () => { + const webhooks = await Webhook.find({ team: teamId }); + + const output = webhooks.map(wh => ({ + id: wh._id.toString(), + name: wh.name, + service: wh.service, + })); + + return { + content: [ + { type: 'text' as const, text: JSON.stringify(output, null, 2) }, + ], + }; + }), + ); +} diff --git a/packages/api/src/mcp/tools/alerts/index.ts b/packages/api/src/mcp/tools/alerts/index.ts new file mode 100644 index 0000000000..d788ab6b9e --- /dev/null +++ b/packages/api/src/mcp/tools/alerts/index.ts @@ -0,0 +1,17 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import type { McpContext, ToolDefinition } from '../types'; +import { registerGetAlert } from './getAlert'; +import { registerGetWebhook } from './getWebhook'; +import { registerSaveAlert } from './saveAlert'; + +const alertsTools: ToolDefinition = ( + server: McpServer, + context: McpContext, +) => { + registerGetAlert(server, context); + registerGetWebhook(server, context); + registerSaveAlert(server, context); +}; + +export default alertsTools; diff --git a/packages/api/src/mcp/tools/alerts/saveAlert.ts b/packages/api/src/mcp/tools/alerts/saveAlert.ts new file mode 100644 index 0000000000..d442f74dc3 --- /dev/null +++ b/packages/api/src/mcp/tools/alerts/saveAlert.ts @@ -0,0 +1,171 @@ +import { AlertThresholdType } from '@hyperdx/common-utils/dist/types'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import mongoose from 'mongoose'; + +import * as config from '@/config'; +import { + type AlertInput, + createAlert, + updateAlert, + validateAlertInput, +} from '@/controllers/alerts'; +import { type AlertChannel, AlertSource } from '@/models/alert'; +import { translateAlertDocumentToExternalAlert } from '@/utils/externalApi'; + +import { withToolTracing } from '../../utils/tracing'; +import type { McpContext } from '../types'; +import { + type McpSaveAlertInput, + mcpSaveAlertSchema, + validateSaveAlertInput, +} from './schemas'; + +/** + * Convert the flat MCP channel object into the discriminated-union + * `AlertChannel` that the controller layer expects. + * + * By the time this is called, `validateSaveAlertInput` has already + * ensured the channel-specific required fields are present. + */ +function toAlertChannel(ch: McpSaveAlertInput['channel']): AlertChannel { + return { + type: 'webhook', + webhookId: ch.webhookId!, + }; +} + +export function registerSaveAlert( + server: McpServer, + context: McpContext, +): void { + const { teamId, userId } = context; + const frontendUrl = config.FRONTEND_URL; + + server.registerTool( + 'hyperdx_save_alert', + { + title: 'Create or Update Alert', + description: + 'Create a new alert (omit id) or update an existing one (provide id). ' + + 'Alerts monitor a saved search or dashboard tile and fire when the ' + + 'metric crosses a threshold. A webhook notification channel is required.', + inputSchema: mcpSaveAlertSchema, + }, + withToolTracing('hyperdx_save_alert', context, async input => { + // ── Runtime cross-field validation ── + const validationError = validateSaveAlertInput(input); + if (validationError) { + return { + isError: true, + content: [{ type: 'text' as const, text: validationError }], + }; + } + + // ── Determine if this is a create or update ── + const isUpdate = !!input.id; + + // ── Validate ID for updates ── + if (isUpdate && !mongoose.Types.ObjectId.isValid(input.id!)) { + return { + isError: true, + content: [{ type: 'text' as const, text: 'Invalid alert ID' }], + }; + } + + // Build the alert input matching the shape expected by controllers. + // The flat MCP channel must be narrowed into the discriminated union, + // and string literals must be cast to their enum counterparts. + // Runtime validation (above) has already verified correctness. + const channel = toAlertChannel(input.channel); + const source = + input.source === 'tile' ? AlertSource.TILE : AlertSource.SAVED_SEARCH; + const alertInput: AlertInput = { + source, + channel, + interval: input.interval, + threshold: input.threshold, + thresholdType: input.thresholdType as AlertThresholdType, + thresholdMax: input.thresholdMax, + scheduleOffsetMinutes: input.scheduleOffsetMinutes, + scheduleStartAt: input.scheduleStartAt, + name: input.name, + message: input.message, + groupBy: input.groupBy, + savedSearchId: input.savedSearchId, + dashboardId: input.dashboardId, + tileId: input.tileId, + }; + + // ── Validate referenced entities exist ── + const mongoTeamId = new mongoose.Types.ObjectId(teamId); + try { + await validateAlertInput(mongoTeamId, alertInput); + } catch (e) { + // Api400Error stores the descriptive message in `name` (e.g. + // "Saved search not found") while `message` is a generic + // "Bad Request". Prefer `name` when it looks descriptive. + const msg = + e instanceof Error && e.name && e.name !== e.constructor.name + ? e.name + : e instanceof Error + ? e.message + : String(e); + return { + isError: true, + content: [{ type: 'text' as const, text: msg }], + }; + } + + const mongoUserId = new mongoose.Types.ObjectId(userId); + + // ── Create or update ── + if (isUpdate) { + const updated = await updateAlert(input.id!, mongoTeamId, alertInput); + if (!updated) { + return { + isError: true, + content: [{ type: 'text' as const, text: 'Alert not found' }], + }; + } + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify( + { + ...translateAlertDocumentToExternalAlert(updated), + ...(frontendUrl ? { url: `${frontendUrl}/alerts` } : {}), + }, + null, + 2, + ), + }, + ], + }; + } + + // Cast to satisfy the compiler – runtime validation (above) has + // already ensured correctness. + const created = await createAlert( + mongoTeamId, + alertInput as Parameters[1], + mongoUserId, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify( + { + ...translateAlertDocumentToExternalAlert(created), + ...(frontendUrl ? { url: `${frontendUrl}/alerts` } : {}), + }, + null, + 2, + ), + }, + ], + }; + }), + ); +} diff --git a/packages/api/src/mcp/tools/alerts/schemas.ts b/packages/api/src/mcp/tools/alerts/schemas.ts new file mode 100644 index 0000000000..3433fdf830 --- /dev/null +++ b/packages/api/src/mcp/tools/alerts/schemas.ts @@ -0,0 +1,171 @@ +import { + ALERT_INTERVAL_TO_MINUTES, + type AlertInterval, + isRangeThresholdType, +} from '@hyperdx/common-utils/dist/types'; +import { z } from 'zod'; + +// --------------------------------------------------------------------------- +// MCP-compatible flat Zod schema for hyperdx_save_alert. +// +// The MCP SDK's normalizeObjectSchema() cannot serialize ZodEffects +// (superRefine) or discriminatedUnion. We keep the inputSchema as a plain +// z.object() and perform cross-field validation at runtime via +// validateSaveAlertInput(). +// --------------------------------------------------------------------------- + +const mcpAlertChannelSchema = z + .object({ + type: z + .literal('webhook') + .describe('Channel type for alert notifications.'), + webhookId: z + .string() + .optional() + .describe('Webhook destination ID (required for webhook channel).'), + }) + .describe('Alert notification channel configuration.'); + +export const mcpSaveAlertSchema = z.object({ + id: z + .string() + .optional() + .describe( + 'Alert ID. Omit to create a new alert, provide to update an existing one.', + ), + + // Source + source: z + .enum(['saved_search', 'tile']) + .describe('Alert source type: saved_search or tile.'), + savedSearchId: z + .string() + .optional() + .describe('Saved search ID (required when source is saved_search).'), + dashboardId: z + .string() + .optional() + .describe('Dashboard ID (required when source is tile).'), + tileId: z + .string() + .optional() + .describe( + 'Tile ID within the dashboard (required when source is tile). Must be a line, stacked bar, or number tile.', + ), + groupBy: z + .string() + .optional() + .describe('Group-by key for saved search alerts.'), + + // Threshold + threshold: z.number().describe('Threshold value for triggering the alert.'), + thresholdType: z + .enum([ + 'above', + 'below', + 'above_exclusive', + 'below_or_equal', + 'equal', + 'not_equal', + 'between', + 'not_between', + ]) + .describe('How the metric value is compared against the threshold.'), + thresholdMax: z + .number() + .optional() + .describe( + 'Upper bound (required when thresholdType is between or not_between, must be >= threshold).', + ), + + // Schedule + interval: z + .enum(['1m', '5m', '15m', '30m', '1h', '6h', '12h', '1d']) + .describe('Evaluation interval.'), + scheduleOffsetMinutes: z + .number() + .int() + .min(0) + .max(1439) + .optional() + .describe( + 'Offset from the interval boundary in minutes (must be < interval).', + ), + scheduleStartAt: z + .string() + .datetime() + .nullable() + .optional() + .describe('Absolute UTC anchor for window alignment (ISO 8601).'), + + // Channel + channel: mcpAlertChannelSchema, + + // Metadata + name: z + .string() + .min(1) + .max(512) + .optional() + .describe('Human-friendly alert name.'), + message: z + .string() + .min(1) + .max(4096) + .optional() + .describe('Alert message template (supports Handlebars syntax).'), +}); + +export type McpSaveAlertInput = z.infer; + +// --------------------------------------------------------------------------- +// Runtime cross-field validation (not in Zod to avoid ZodEffects). +// Returns a human-readable error string, or null when valid. +// --------------------------------------------------------------------------- +export function validateSaveAlertInput(data: McpSaveAlertInput): string | null { + // Source-specific required fields + if (data.source === 'tile') { + if (!data.dashboardId) { + return 'dashboardId is required when source is "tile"'; + } + if (!data.tileId) { + return 'tileId is required when source is "tile"'; + } + } + if (data.source === 'saved_search') { + if (!data.savedSearchId) { + return 'savedSearchId is required when source is "saved_search"'; + } + } + + // Threshold range checks + if (isRangeThresholdType(data.thresholdType)) { + if (data.thresholdMax == null) { + return `thresholdMax is required when thresholdType is "${data.thresholdType}"`; + } + if (data.thresholdMax < data.threshold) { + return 'thresholdMax must be >= threshold'; + } + } + + // Schedule offset must be less than the interval + if (data.scheduleOffsetMinutes != null) { + const intervalMinutes = + ALERT_INTERVAL_TO_MINUTES[data.interval as AlertInterval]; + if ( + intervalMinutes != null && + data.scheduleOffsetMinutes >= intervalMinutes + ) { + return `scheduleOffsetMinutes (${data.scheduleOffsetMinutes}) must be less than the interval (${data.interval} = ${intervalMinutes} minutes)`; + } + } + + // Channel-specific required fields + if (data.channel.type === 'webhook') { + if (!data.channel.webhookId) { + return 'webhookId is required when channel type is "webhook"'; + } + } + + return null; +} diff --git a/packages/api/src/mcp/tools/savedSearches/getSavedSearch.ts b/packages/api/src/mcp/tools/savedSearches/getSavedSearch.ts new file mode 100644 index 0000000000..9a6fa24b59 --- /dev/null +++ b/packages/api/src/mcp/tools/savedSearches/getSavedSearch.ts @@ -0,0 +1,88 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import mongoose from 'mongoose'; +import { z } from 'zod'; + +import * as config from '@/config'; +import { getSavedSearch, getSavedSearches } from '@/controllers/savedSearch'; + +import { withToolTracing } from '../../utils/tracing'; +import type { McpContext } from '../types'; + +export function registerGetSavedSearch( + server: McpServer, + context: McpContext, +): void { + const { teamId } = context; + const frontendUrl = config.FRONTEND_URL; + + server.registerTool( + 'hyperdx_get_saved_search', + { + title: 'Get Saved Search(es)', + description: + 'Without an ID: list all saved searches as a high-level summary ' + + '(id, name, tags). ' + + 'With an ID: get full saved search detail including query, source, ' + + 'filters, and configuration.', + inputSchema: z.object({ + id: z + .string() + .optional() + .describe( + 'Saved search ID. Omit to list all saved searches, provide to get full detail.', + ), + }), + }, + withToolTracing('hyperdx_get_saved_search', context, async ({ id }) => { + // ── List all saved searches ── + if (!id) { + const savedSearches = await getSavedSearches(teamId); + const output = savedSearches.map(ss => ({ + id: ss.id, + name: ss.name, + tags: ss.tags, + ...(frontendUrl ? { url: `${frontendUrl}/search/${ss.id}` } : {}), + })); + return { + content: [ + { type: 'text' as const, text: JSON.stringify(output, null, 2) }, + ], + }; + } + + // ── Get single saved search ── + if (!mongoose.Types.ObjectId.isValid(id)) { + return { + isError: true, + content: [{ type: 'text' as const, text: 'Invalid saved search ID' }], + }; + } + + const savedSearch = await getSavedSearch(teamId, id); + if (!savedSearch) { + return { + isError: true, + content: [{ type: 'text' as const, text: 'Saved search not found' }], + }; + } + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify( + { + ...savedSearch.toJSON(), + ...(frontendUrl + ? { url: `${frontendUrl}/search/${savedSearch._id}` } + : {}), + }, + null, + 2, + ), + }, + ], + }; + }), + ); +} diff --git a/packages/api/src/mcp/tools/savedSearches/index.ts b/packages/api/src/mcp/tools/savedSearches/index.ts new file mode 100644 index 0000000000..d677f5a713 --- /dev/null +++ b/packages/api/src/mcp/tools/savedSearches/index.ts @@ -0,0 +1,15 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import type { McpContext, ToolDefinition } from '../types'; +import { registerGetSavedSearch } from './getSavedSearch'; +import { registerSaveSavedSearch } from './saveSavedSearch'; + +const savedSearchesTools: ToolDefinition = ( + server: McpServer, + context: McpContext, +) => { + registerGetSavedSearch(server, context); + registerSaveSavedSearch(server, context); +}; + +export default savedSearchesTools; diff --git a/packages/api/src/mcp/tools/savedSearches/saveSavedSearch.ts b/packages/api/src/mcp/tools/savedSearches/saveSavedSearch.ts new file mode 100644 index 0000000000..248261769e --- /dev/null +++ b/packages/api/src/mcp/tools/savedSearches/saveSavedSearch.ts @@ -0,0 +1,146 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import mongoose from 'mongoose'; + +import * as config from '@/config'; +import { + createSavedSearch, + getSavedSearch, + updateSavedSearch, +} from '@/controllers/savedSearch'; +import { getSource } from '@/controllers/sources'; + +import { withToolTracing } from '../../utils/tracing'; +import type { McpContext } from '../types'; +import { mcpSaveSavedSearchSchema } from './schemas'; + +export function registerSaveSavedSearch( + server: McpServer, + context: McpContext, +): void { + const { teamId, userId } = context; + const frontendUrl = config.FRONTEND_URL; + + server.registerTool( + 'hyperdx_save_saved_search', + { + title: 'Create or Update Saved Search', + description: + 'Create a new saved search (omit id) or update an existing one (provide id). ' + + 'A saved search stores a reusable query against a data source. ' + + 'Use hyperdx_list_sources to find the sourceId.', + inputSchema: mcpSaveSavedSearchSchema, + }, + withToolTracing('hyperdx_save_saved_search', context, async input => { + const isUpdate = !!input.id; + + // ── Validate ID for updates ── + if (isUpdate && !mongoose.Types.ObjectId.isValid(input.id!)) { + return { + isError: true, + content: [{ type: 'text' as const, text: 'Invalid saved search ID' }], + }; + } + + // ── Validate sourceId ── + if (!mongoose.Types.ObjectId.isValid(input.sourceId)) { + return { + isError: true, + content: [{ type: 'text' as const, text: 'Invalid sourceId' }], + }; + } + const source = await getSource(teamId, input.sourceId); + if (!source) { + return { + isError: true, + content: [{ type: 'text' as const, text: 'Source not found' }], + }; + } + + // Build the saved search data matching what the controller expects. + const savedSearchData = { + name: input.name, + select: input.select ?? '', + where: input.where ?? '', + whereLanguage: input.whereLanguage, + orderBy: input.orderBy, + source: input.sourceId, + tags: input.tags ?? [], + filters: input.filters, + }; + + if (isUpdate) { + // Verify the saved search exists before updating. + const existing = await getSavedSearch(teamId, input.id!); + if (!existing) { + return { + isError: true, + content: [ + { type: 'text' as const, text: 'Saved search not found' }, + ], + }; + } + + const updated = await updateSavedSearch( + teamId, + input.id!, + { + ...existing.toJSON(), + ...savedSearchData, + }, + userId, + ); + + if (!updated) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: 'Failed to update saved search', + }, + ], + }; + } + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify( + { + ...updated.toJSON(), + ...(frontendUrl + ? { url: `${frontendUrl}/search/${updated._id}` } + : {}), + }, + null, + 2, + ), + }, + ], + }; + } + + // ── Create ── + const created = await createSavedSearch(teamId, savedSearchData, userId); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify( + { + ...created.toJSON(), + ...(frontendUrl + ? { url: `${frontendUrl}/search/${created._id}` } + : {}), + }, + null, + 2, + ), + }, + ], + }; + }), + ); +} diff --git a/packages/api/src/mcp/tools/savedSearches/schemas.ts b/packages/api/src/mcp/tools/savedSearches/schemas.ts new file mode 100644 index 0000000000..06eb5bc386 --- /dev/null +++ b/packages/api/src/mcp/tools/savedSearches/schemas.ts @@ -0,0 +1,75 @@ +import { z } from 'zod'; + +// --------------------------------------------------------------------------- +// MCP-compatible Zod schemas for saved search tools. +// --------------------------------------------------------------------------- + +const mcpFilterSchema = z + .union([ + z.object({ + type: z + .enum(['lucene', 'sql']) + .describe('Filter language: lucene or sql.'), + condition: z.string().describe('Filter condition string.'), + }), + z.object({ + type: z.literal('sql_ast').describe('Structured SQL AST filter.'), + operator: z + .enum(['=', '<', '>', '!=', '<=', '>=']) + .describe('Comparison operator.'), + left: z.string().describe('Left operand (column or expression).'), + right: z.string().describe('Right operand (value or expression).'), + }), + ]) + .describe('Filter applied to the saved search.'); + +export const mcpSaveSavedSearchSchema = z.object({ + id: z + .string() + .optional() + .describe( + 'Saved search ID. Omit to create a new saved search, provide to update an existing one.', + ), + name: z + .string() + .min(1) + .max(512) + .describe('Human-friendly name for the saved search.'), + select: z + .string() + .optional() + .describe( + 'Columns/fields to retrieve. Leave empty for defaults. ' + + 'Example: "body,service.name,duration"', + ), + where: z + .string() + .optional() + .describe( + 'Filter condition string in the language specified by whereLanguage. ' + + 'Example (lucene): "level:error", Example (sql): "StatusCode = \'Error\'"', + ), + whereLanguage: z + .enum(['sql', 'lucene']) + .optional() + .describe('Language for the where filter. Default: lucene.'), + orderBy: z + .string() + .optional() + .describe('Sort expression. Example: "Timestamp DESC"'), + sourceId: z + .string() + .describe( + 'Source ID — call hyperdx_list_sources to find available sources.', + ), + tags: z + .array(z.string()) + .optional() + .describe('Tags for organizing saved searches.'), + filters: z + .array(mcpFilterSchema) + .optional() + .describe('Additional structured filters.'), +}); + +export type McpSaveSavedSearchInput = z.infer; diff --git a/packages/api/src/mcp/tools/types.ts b/packages/api/src/mcp/tools/types.ts index c0d669ad60..c3cc2d293e 100644 --- a/packages/api/src/mcp/tools/types.ts +++ b/packages/api/src/mcp/tools/types.ts @@ -2,7 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; export type McpContext = { teamId: string; - userId?: string; + userId: string; }; export type ToolDefinition = (server: McpServer, context: McpContext) => void; diff --git a/packages/api/src/mcp/utils/tracing.ts b/packages/api/src/mcp/utils/tracing.ts index 547330d5d9..38809bcd81 100644 --- a/packages/api/src/mcp/utils/tracing.ts +++ b/packages/api/src/mcp/utils/tracing.ts @@ -32,9 +32,7 @@ export function withToolTracing( span.setAttribute('mcp.tool.name', toolName); span.setAttribute('mcp.team.id', context.teamId); - if (context.userId) { - span.setAttribute('mcp.user.id', context.userId); - } + span.setAttribute('mcp.user.id', context.userId); logger.info(logContext, `MCP tool invoked: ${toolName}`); From ef9669dca92509eeae8839bac50dfc6f284ec902 Mon Sep 17 00:00:00 2001 From: Brandon Pereira Date: Wed, 13 May 2026 16:42:38 -0600 Subject: [PATCH 2/8] chore: add changeset for MCP alert and saved search tools --- .changeset/mcp-alert-saved-search-tools.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/mcp-alert-saved-search-tools.md diff --git a/.changeset/mcp-alert-saved-search-tools.md b/.changeset/mcp-alert-saved-search-tools.md new file mode 100644 index 0000000000..f586a0b14e --- /dev/null +++ b/.changeset/mcp-alert-saved-search-tools.md @@ -0,0 +1,12 @@ +--- +'@hyperdx/api': minor +--- + +feat(mcp): add alert, saved search, and webhook MCP tools + +Add five new MCP tools for managing alerts, saved searches, and webhooks: +- `hyperdx_get_alert` / `hyperdx_save_alert` for listing, creating, and updating alerts +- `hyperdx_get_webhook` for listing webhook destinations +- `hyperdx_get_saved_search` / `hyperdx_save_saved_search` for listing, creating, and updating saved searches + +Also makes `McpContext.userId` required, rejecting MCP requests without a user ID. From 669bd7f7c6fa2bf56a3f807cc38be3526cb62b66 Mon Sep 17 00:00:00 2001 From: Brandon Pereira Date: Wed, 13 May 2026 16:43:35 -0600 Subject: [PATCH 3/8] fix: remove unused McpSaveSavedSearchInput export (knip) --- packages/api/src/mcp/tools/savedSearches/schemas.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/api/src/mcp/tools/savedSearches/schemas.ts b/packages/api/src/mcp/tools/savedSearches/schemas.ts index 06eb5bc386..d3348be3d9 100644 --- a/packages/api/src/mcp/tools/savedSearches/schemas.ts +++ b/packages/api/src/mcp/tools/savedSearches/schemas.ts @@ -71,5 +71,3 @@ export const mcpSaveSavedSearchSchema = z.object({ .optional() .describe('Additional structured filters.'), }); - -export type McpSaveSavedSearchInput = z.infer; From 2ead73b3312eba75222dc8a639b6984fd708194c Mon Sep 17 00:00:00 2001 From: Brandon Pereira Date: Wed, 13 May 2026 16:49:09 -0600 Subject: [PATCH 4/8] fix(mcp): drop populated-doc spread in saved search update and use explicit BaseError instanceof check - saveSavedSearch: remove ...existing.toJSON() spread that passed a populated createdBy object where Mongoose expects an ObjectId. savedSearchData already contains every user-editable field. - saveAlert: replace stringly-typed e.name heuristic with explicit e instanceof BaseError check for error message extraction. --- packages/api/src/mcp/tools/alerts/saveAlert.ts | 8 ++++---- .../api/src/mcp/tools/savedSearches/saveSavedSearch.ts | 5 +---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/api/src/mcp/tools/alerts/saveAlert.ts b/packages/api/src/mcp/tools/alerts/saveAlert.ts index d442f74dc3..207a30c59e 100644 --- a/packages/api/src/mcp/tools/alerts/saveAlert.ts +++ b/packages/api/src/mcp/tools/alerts/saveAlert.ts @@ -10,6 +10,7 @@ import { validateAlertInput, } from '@/controllers/alerts'; import { type AlertChannel, AlertSource } from '@/models/alert'; +import { BaseError } from '@/utils/errors'; import { translateAlertDocumentToExternalAlert } from '@/utils/externalApi'; import { withToolTracing } from '../../utils/tracing'; @@ -101,11 +102,10 @@ export function registerSaveAlert( try { await validateAlertInput(mongoTeamId, alertInput); } catch (e) { - // Api400Error stores the descriptive message in `name` (e.g. - // "Saved search not found") while `message` is a generic - // "Bad Request". Prefer `name` when it looks descriptive. + // BaseError subclasses (Api400Error, Api404Error, etc.) store the + // descriptive message in `name` and a generic string in `message`. const msg = - e instanceof Error && e.name && e.name !== e.constructor.name + e instanceof BaseError ? e.name : e instanceof Error ? e.message diff --git a/packages/api/src/mcp/tools/savedSearches/saveSavedSearch.ts b/packages/api/src/mcp/tools/savedSearches/saveSavedSearch.ts index 248261769e..e6ae4c429e 100644 --- a/packages/api/src/mcp/tools/savedSearches/saveSavedSearch.ts +++ b/packages/api/src/mcp/tools/savedSearches/saveSavedSearch.ts @@ -83,10 +83,7 @@ export function registerSaveSavedSearch( const updated = await updateSavedSearch( teamId, input.id!, - { - ...existing.toJSON(), - ...savedSearchData, - }, + savedSearchData, userId, ); From beb9ec485608cc40daced37270519193b044eacd Mon Sep 17 00:00:00 2001 From: Brandon Pereira Date: Wed, 13 May 2026 17:12:08 -0600 Subject: [PATCH 5/8] =?UTF-8?q?fix(mcp):=20address=20PR=20feedback=20?= =?UTF-8?q?=E2=80=94=20consistent=20saved=20search=20response=20shape,=20s?= =?UTF-8?q?lim=20list=20query,=20simplify=20channel=20schema,=20remove=20n?= =?UTF-8?q?on-null=20assertions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add translateSavedSearchDocumentToExternalSavedSearch translator so saved search responses use 'id' (not '_id'), string IDs, and no __v, consistent with alert/dashboard tools. - Replace getSavedSearches controller call with a slim SavedSearch.find(team, 'name tags').lean() for the list endpoint, avoiding unnecessary Alert.find and populate calls. - Make webhookId required in the Zod schema (was optional with runtime check), removing the dead-branch channel validation and the non-null assertion in toAlertChannel. - Replace input.id! non-null assertions with early-return narrowing via a local alertId variable. - Update saved search tests to expect the new external response shape. --- .../src/mcp/__tests__/savedSearches.test.ts | 19 +++++--- .../api/src/mcp/tools/alerts/saveAlert.ts | 25 ++++------ packages/api/src/mcp/tools/alerts/schemas.ts | 8 ---- .../mcp/tools/savedSearches/getSavedSearch.ts | 19 +++++--- .../tools/savedSearches/saveSavedSearch.ts | 5 +- packages/api/src/utils/externalApi.ts | 48 +++++++++++++++++++ 6 files changed, 85 insertions(+), 39 deletions(-) diff --git a/packages/api/src/mcp/__tests__/savedSearches.test.ts b/packages/api/src/mcp/__tests__/savedSearches.test.ts index cb1378013b..ee33a1f01f 100644 --- a/packages/api/src/mcp/__tests__/savedSearches.test.ts +++ b/packages/api/src/mcp/__tests__/savedSearches.test.ts @@ -109,7 +109,7 @@ describe('MCP Saved Search Tools', () => { // Detail fields should NOT be present in list mode expect(output[0]).not.toHaveProperty('where'); expect(output[0]).not.toHaveProperty('whereLanguage'); - expect(output[0]).not.toHaveProperty('source'); + expect(output[0]).not.toHaveProperty('sourceId'); expect(output[0]).not.toHaveProperty('select'); expect(output[0]).not.toHaveProperty('filters'); }); @@ -156,13 +156,17 @@ describe('MCP Saved Search Tools', () => { expect(result.isError).toBeFalsy(); const output = JSON.parse(getFirstText(result)); - expect(output._id).toBe(savedSearch._id.toString()); + // External format uses 'id' not '_id' + expect(output.id).toBe(savedSearch._id.toString()); + expect(output).not.toHaveProperty('_id'); expect(output.name).toBe('Detail Test'); expect(output.where).toBe('level:error'); // Full detail includes fields not in the list summary - expect(output).toHaveProperty('source'); + expect(output).toHaveProperty('sourceId'); expect(output).toHaveProperty('whereLanguage'); expect(output).toHaveProperty('tags'); + expect(output).toHaveProperty('teamId'); + expect(output).toHaveProperty('createdAt'); }); it('should return error for invalid ObjectId format', async () => { @@ -201,14 +205,16 @@ describe('MCP Saved Search Tools', () => { expect(result.isError).toBeFalsy(); const output = JSON.parse(getFirstText(result)); - expect(output._id).toBeDefined(); + // External format uses 'id' not '_id' + expect(output.id).toBeDefined(); + expect(output).not.toHaveProperty('_id'); expect(output.name).toBe('Error Traces'); expect(output.where).toBe('StatusCode:Error'); expect(output.whereLanguage).toBe('lucene'); expect(output.tags).toEqual(['errors']); // Verify in database - const savedSearch = await SavedSearch.findById(output._id); + const savedSearch = await SavedSearch.findById(output.id); expect(savedSearch).not.toBeNull(); expect(savedSearch?.name).toBe('Error Traces'); }); @@ -318,7 +324,8 @@ describe('MCP Saved Search Tools', () => { expect(result.isError).toBeFalsy(); const output = JSON.parse(getFirstText(result)); - expect(output._id).toBe(savedSearch._id.toString()); + expect(output.id).toBe(savedSearch._id.toString()); + expect(output).not.toHaveProperty('_id'); expect(output.name).toBe('Updated Name'); expect(output.where).toBe('StatusCode:Ok'); expect(output.tags).toEqual(['updated']); diff --git a/packages/api/src/mcp/tools/alerts/saveAlert.ts b/packages/api/src/mcp/tools/alerts/saveAlert.ts index 207a30c59e..c693858b8e 100644 --- a/packages/api/src/mcp/tools/alerts/saveAlert.ts +++ b/packages/api/src/mcp/tools/alerts/saveAlert.ts @@ -24,14 +24,11 @@ import { /** * Convert the flat MCP channel object into the discriminated-union * `AlertChannel` that the controller layer expects. - * - * By the time this is called, `validateSaveAlertInput` has already - * ensured the channel-specific required fields are present. */ function toAlertChannel(ch: McpSaveAlertInput['channel']): AlertChannel { return { type: 'webhook', - webhookId: ch.webhookId!, + webhookId: ch.webhookId, }; } @@ -62,11 +59,9 @@ export function registerSaveAlert( }; } - // ── Determine if this is a create or update ── - const isUpdate = !!input.id; - - // ── Validate ID for updates ── - if (isUpdate && !mongoose.Types.ObjectId.isValid(input.id!)) { + // ── Validate ID for updates (early return narrows input.id to string) ── + const alertId = input.id; + if (alertId != null && !mongoose.Types.ObjectId.isValid(alertId)) { return { isError: true, content: [{ type: 'text' as const, text: 'Invalid alert ID' }], @@ -74,9 +69,6 @@ export function registerSaveAlert( } // Build the alert input matching the shape expected by controllers. - // The flat MCP channel must be narrowed into the discriminated union, - // and string literals must be cast to their enum counterparts. - // Runtime validation (above) has already verified correctness. const channel = toAlertChannel(input.channel); const source = input.source === 'tile' ? AlertSource.TILE : AlertSource.SAVED_SEARCH; @@ -118,9 +110,9 @@ export function registerSaveAlert( const mongoUserId = new mongoose.Types.ObjectId(userId); - // ── Create or update ── - if (isUpdate) { - const updated = await updateAlert(input.id!, mongoTeamId, alertInput); + // ── Update existing alert ── + if (alertId) { + const updated = await updateAlert(alertId, mongoTeamId, alertInput); if (!updated) { return { isError: true, @@ -144,8 +136,7 @@ export function registerSaveAlert( }; } - // Cast to satisfy the compiler – runtime validation (above) has - // already ensured correctness. + // ── Create new alert ── const created = await createAlert( mongoTeamId, alertInput as Parameters[1], diff --git a/packages/api/src/mcp/tools/alerts/schemas.ts b/packages/api/src/mcp/tools/alerts/schemas.ts index 3433fdf830..d5878e9847 100644 --- a/packages/api/src/mcp/tools/alerts/schemas.ts +++ b/packages/api/src/mcp/tools/alerts/schemas.ts @@ -21,7 +21,6 @@ const mcpAlertChannelSchema = z .describe('Channel type for alert notifications.'), webhookId: z .string() - .optional() .describe('Webhook destination ID (required for webhook channel).'), }) .describe('Alert notification channel configuration.'); @@ -160,12 +159,5 @@ export function validateSaveAlertInput(data: McpSaveAlertInput): string | null { } } - // Channel-specific required fields - if (data.channel.type === 'webhook') { - if (!data.channel.webhookId) { - return 'webhookId is required when channel type is "webhook"'; - } - } - return null; } diff --git a/packages/api/src/mcp/tools/savedSearches/getSavedSearch.ts b/packages/api/src/mcp/tools/savedSearches/getSavedSearch.ts index 9a6fa24b59..a0fffc9e26 100644 --- a/packages/api/src/mcp/tools/savedSearches/getSavedSearch.ts +++ b/packages/api/src/mcp/tools/savedSearches/getSavedSearch.ts @@ -3,7 +3,9 @@ import mongoose from 'mongoose'; import { z } from 'zod'; import * as config from '@/config'; -import { getSavedSearch, getSavedSearches } from '@/controllers/savedSearch'; +import { getSavedSearch } from '@/controllers/savedSearch'; +import { SavedSearch } from '@/models/savedSearch'; +import { translateSavedSearchDocumentToExternalSavedSearch } from '@/utils/externalApi'; import { withToolTracing } from '../../utils/tracing'; import type { McpContext } from '../types'; @@ -34,14 +36,17 @@ export function registerGetSavedSearch( }), }, withToolTracing('hyperdx_get_saved_search', context, async ({ id }) => { - // ── List all saved searches ── + // ── List all saved searches (slim query — only fetch the fields we need) ── if (!id) { - const savedSearches = await getSavedSearches(teamId); + const savedSearches = await SavedSearch.find( + { team: teamId }, + 'name tags', + ).lean(); const output = savedSearches.map(ss => ({ - id: ss.id, + id: ss._id.toString(), name: ss.name, tags: ss.tags, - ...(frontendUrl ? { url: `${frontendUrl}/search/${ss.id}` } : {}), + ...(frontendUrl ? { url: `${frontendUrl}/search/${ss._id}` } : {}), })); return { content: [ @@ -72,7 +77,9 @@ export function registerGetSavedSearch( type: 'text' as const, text: JSON.stringify( { - ...savedSearch.toJSON(), + ...translateSavedSearchDocumentToExternalSavedSearch( + savedSearch, + ), ...(frontendUrl ? { url: `${frontendUrl}/search/${savedSearch._id}` } : {}), diff --git a/packages/api/src/mcp/tools/savedSearches/saveSavedSearch.ts b/packages/api/src/mcp/tools/savedSearches/saveSavedSearch.ts index e6ae4c429e..9c2d134fa2 100644 --- a/packages/api/src/mcp/tools/savedSearches/saveSavedSearch.ts +++ b/packages/api/src/mcp/tools/savedSearches/saveSavedSearch.ts @@ -8,6 +8,7 @@ import { updateSavedSearch, } from '@/controllers/savedSearch'; import { getSource } from '@/controllers/sources'; +import { translateSavedSearchDocumentToExternalSavedSearch } from '@/utils/externalApi'; import { withToolTracing } from '../../utils/tracing'; import type { McpContext } from '../types'; @@ -105,7 +106,7 @@ export function registerSaveSavedSearch( type: 'text' as const, text: JSON.stringify( { - ...updated.toJSON(), + ...translateSavedSearchDocumentToExternalSavedSearch(updated), ...(frontendUrl ? { url: `${frontendUrl}/search/${updated._id}` } : {}), @@ -127,7 +128,7 @@ export function registerSaveSavedSearch( type: 'text' as const, text: JSON.stringify( { - ...created.toJSON(), + ...translateSavedSearchDocumentToExternalSavedSearch(created), ...(frontendUrl ? { url: `${frontendUrl}/search/${created._id}` } : {}), diff --git a/packages/api/src/utils/externalApi.ts b/packages/api/src/utils/externalApi.ts index 1c6debccd4..9387d1b982 100644 --- a/packages/api/src/utils/externalApi.ts +++ b/packages/api/src/utils/externalApi.ts @@ -359,3 +359,51 @@ export function translateAlertDocumentToExternalAlert( return result; } + +// Saved search related types and transformations +export type ExternalSavedSearch = { + id: string; + name: string; + select?: string; + where?: string; + whereLanguage?: string; + orderBy?: string; + sourceId?: string; + tags?: string[]; + filters?: unknown[]; + teamId: string; + createdAt?: string; + updatedAt?: string; +}; + +export function translateSavedSearchDocumentToExternalSavedSearch(doc: { + _id: unknown; + name?: string; + select?: string; + where?: string; + whereLanguage?: string; + orderBy?: string; + source?: unknown; + tags?: string[]; + filters?: unknown[]; + team?: unknown; + createdAt?: Date; + updatedAt?: Date; +}): ExternalSavedSearch { + return { + id: String(doc._id), + name: doc.name ?? '', + select: doc.select, + where: doc.where, + whereLanguage: doc.whereLanguage, + orderBy: doc.orderBy, + sourceId: doc.source ? String(doc.source) : undefined, + tags: doc.tags, + filters: doc.filters, + teamId: doc.team ? String(doc.team) : '', + createdAt: + doc.createdAt instanceof Date ? doc.createdAt.toISOString() : undefined, + updatedAt: + doc.updatedAt instanceof Date ? doc.updatedAt.toISOString() : undefined, + }; +} From 33a242fb52d9c715b266ef18911d6fca35f90eea Mon Sep 17 00:00:00 2001 From: Brandon Pereira Date: Thu, 14 May 2026 08:42:54 -0600 Subject: [PATCH 6/8] remove coverage to reduce memory usage in ci int tests --- packages/api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/package.json b/packages/api/package.json index 56a556cf66..16c4e419a5 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -91,7 +91,7 @@ "lint": "npx eslint --quiet . --ext .ts", "lint:fix": "npx eslint . --ext .ts --fix", "ci:lint": "yarn lint && yarn tsc --noEmit && yarn lint:openapi", - "ci:int": "DOTENV_CONFIG_PATH=.env.test DOTENV_CONFIG_OVERRIDE=true jest --runInBand --ci --forceExit --coverage", + "ci:int": "DOTENV_CONFIG_PATH=.env.test DOTENV_CONFIG_OVERRIDE=true jest --runInBand --ci --forceExit", "dev:int": "DOTENV_CONFIG_PATH=.env.test DOTENV_CONFIG_OVERRIDE=true jest --runInBand --coverage", "dev:migrate-db-create": "ts-node node_modules/.bin/migrate-mongo create -f migrate-mongo-config.ts", "dev:migrate-db": "ts-node node_modules/.bin/migrate-mongo up -f migrate-mongo-config.ts", From ba0bf2f3d3471cd189f3d7b716e89338d984d02e Mon Sep 17 00:00:00 2001 From: Brandon Pereira Date: Thu, 14 May 2026 09:28:47 -0600 Subject: [PATCH 7/8] ensure logs are streamed back to user in int, suppress noisy log in int tests --- Makefile | 2 +- packages/common-utils/src/queryParser.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 413696d3ea..5f42b87648 100644 --- a/Makefile +++ b/Makefile @@ -174,7 +174,7 @@ dev-int-common-utils: ci-int: @mkdir -p $(HDX_CI_LOGS_DIR) docker compose -p $(HDX_CI_PROJECT) -f ./docker-compose.ci.yml up -d --quiet-pull - bash -c 'set -o pipefail; npx nx run-many -t ci:int --parallel=false 2>&1 | tee $(HDX_CI_LOGS_DIR)/ci-int.log'; ret=$$?; \ + bash -c 'set -o pipefail; npx nx run-many -t ci:int --parallel=false --output-style=stream 2>&1 | tee $(HDX_CI_LOGS_DIR)/ci-int.log'; ret=$$?; \ docker compose -p $(HDX_CI_PROJECT) -f ./docker-compose.ci.yml down; \ $(call archive-int-logs); \ exit $$ret diff --git a/packages/common-utils/src/queryParser.ts b/packages/common-utils/src/queryParser.ts index c59777ded1..dd5134e57f 100644 --- a/packages/common-utils/src/queryParser.ts +++ b/packages/common-utils/src/queryParser.ts @@ -1024,13 +1024,13 @@ export class CustomSchemaSQLSerializerV2 extends SQLSerializer { }) .then(value => value === '1') .catch(error => { - console.error('Error fetching enable_full_text_index setting:', error); + console.warn('Error fetching enable_full_text_index setting:', error); return false; }); // Pre-fetch KV items lookup (map column -> KV items column with text(tokenizer=array) index) this.kvItemsLookupPromise = this.buildKvItemsLookup().catch(error => { - console.error('Error building KV items lookup:', error); + console.warn('Error building KV items lookup:', error); return new Map(); }); } From 71cea47c05e294208cfd918d0c71f6f23af0be7b Mon Sep 17 00:00:00 2001 From: Brandon Pereira Date: Thu, 14 May 2026 10:19:16 -0600 Subject: [PATCH 8/8] fix(test): use fake team context for team-scoping tests and downgrade queryParser errors to warnings - Replace getLoggedInAgent with second credentials in alert team-scoping tests with a fabricated McpContext using a non-existent teamId. The single-tenant registration route returns 409 when a team already exists, making it impossible to register a second user mid-test. - Use client?.close() in all MCP test afterEach hooks to prevent cascade failures when beforeEach fails. - Downgrade console.error to console.warn in queryParser for non-fatal buildKvItemsLookup and enable_full_text_index fallbacks. --- packages/api/src/mcp/__tests__/alerts.test.ts | 39 +++++++------------ .../api/src/mcp/__tests__/dashboards.test.ts | 2 +- .../src/mcp/__tests__/savedSearches.test.ts | 2 +- 3 files changed, 15 insertions(+), 28 deletions(-) diff --git a/packages/api/src/mcp/__tests__/alerts.test.ts b/packages/api/src/mcp/__tests__/alerts.test.ts index f8821c9b13..803ec9e8de 100644 --- a/packages/api/src/mcp/__tests__/alerts.test.ts +++ b/packages/api/src/mcp/__tests__/alerts.test.ts @@ -63,7 +63,7 @@ describe('MCP Alert Tools', () => { }); afterEach(async () => { - await client.close(); + await client?.close(); await server.clearDBs(); }); @@ -191,16 +191,11 @@ describe('MCP Alert Tools', () => { it('should not return alerts from another team', async () => { await createTestAlert({ name: 'Team Scoped' }); - // Create a new team/user with its own client - const result2 = await getLoggedInAgent(server, { - email: 'other-team-user@test.com', - password: 'TacoCat!2#4X', - }); - const context2: McpContext = { - teamId: result2.team._id.toString(), - userId: result2.user._id.toString(), + const otherTeamContext: McpContext = { + teamId: '000000000000000000000099', + userId: user._id.toString(), }; - const client2 = await createTestClient(context2); + const client2 = await createTestClient(otherTeamContext); // List should be empty for the other team const listResult = await callTool(client2, 'hyperdx_get_alert', {}); @@ -253,15 +248,11 @@ describe('MCP Alert Tools', () => { it('should not return alert from another team by id', async () => { const alert = await createTestAlert({ name: 'Team Scoped' }); - const result2 = await getLoggedInAgent(server, { - email: 'other-team-user@test.com', - password: 'TacoCat!2#4X', - }); - const context2: McpContext = { - teamId: result2.team._id.toString(), - userId: result2.user._id.toString(), + const otherTeamContext: McpContext = { + teamId: '000000000000000000000099', + userId: user._id.toString(), }; - const client2 = await createTestClient(context2); + const client2 = await createTestClient(otherTeamContext); const getResult = await callTool(client2, 'hyperdx_get_alert', { id: alert._id.toString(), @@ -612,15 +603,11 @@ describe('MCP Alert Tools', () => { url: 'https://example.com/hook', }); - const result2 = await getLoggedInAgent(server, { - email: 'other-team-user@test.com', - password: 'TacoCat!2#4X', - }); - const context2: McpContext = { - teamId: result2.team._id.toString(), - userId: result2.user._id.toString(), + const otherTeamContext: McpContext = { + teamId: '000000000000000000000099', + userId: user._id.toString(), }; - const client2 = await createTestClient(context2); + const client2 = await createTestClient(otherTeamContext); const listResult = await callTool(client2, 'hyperdx_get_webhook', {}); const output = JSON.parse(getFirstText(listResult)); diff --git a/packages/api/src/mcp/__tests__/dashboards.test.ts b/packages/api/src/mcp/__tests__/dashboards.test.ts index 90a34292f8..7b822af957 100644 --- a/packages/api/src/mcp/__tests__/dashboards.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards.test.ts @@ -62,7 +62,7 @@ describe('MCP Dashboard Tools', () => { }); afterEach(async () => { - await client.close(); + await client?.close(); await server.clearDBs(); }); diff --git a/packages/api/src/mcp/__tests__/savedSearches.test.ts b/packages/api/src/mcp/__tests__/savedSearches.test.ts index ee33a1f01f..b63b752d95 100644 --- a/packages/api/src/mcp/__tests__/savedSearches.test.ts +++ b/packages/api/src/mcp/__tests__/savedSearches.test.ts @@ -60,7 +60,7 @@ describe('MCP Saved Search Tools', () => { }); afterEach(async () => { - await client.close(); + await client?.close(); await server.clearDBs(); });