Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nasty-crews-shop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hyperdx/api": patch
---

fix(mcp): add ClickHouse safety settings (max_execution_time, max_result_rows, readonly) for MCP query execution
13 changes: 13 additions & 0 deletions packages/api/src/mcp/__tests__/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,19 @@ describe('errorHint', () => {
expect(hint).toContain('LIMIT');
});

it('should match RESULT_IS_TOO_LARGE errors', () => {
const hint = errorHint('Code: 396. DB::Exception: RESULT_IS_TOO_LARGE');
expect(hint).not.toBeNull();
expect(hint).toContain('100,000 rows');
expect(hint).toContain('LIMIT');
});

it('should match TOO_MANY_ROWS_OR_BYTES errors', () => {
const hint = errorHint('Code: 396. DB::Exception: TOO_MANY_ROWS_OR_BYTES');
expect(hint).not.toBeNull();
expect(hint).toContain('100,000 rows');
});

it('should return null for unrecognized errors', () => {
const hint = errorHint('Connection refused');
expect(hint).toBeNull();
Expand Down
107 changes: 107 additions & 0 deletions packages/api/src/mcp/__tests__/queryTool.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/node';
import { SourceKind } from '@hyperdx/common-utils/dist/types';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';

Expand Down Expand Up @@ -583,4 +584,110 @@ describe('MCP Query Tools', () => {
expect(getFirstText(result)).toContain('Invalid');
});
});

// ─── Safety settings (readonly + max_execution_time) ─────────────────────────

describe('ClickHouse safety settings', () => {
describe('readonly enforcement via clickstack_sql', () => {
it('should reject CREATE TABLE (DDL)', async () => {
const result = await callTool(client, 'clickstack_sql', {
connectionId: connection._id.toString(),
sql: 'CREATE TABLE __mcp_test_ddl (id UInt64) ENGINE = Memory',
});

expect(result.isError).toBe(true);
const text = getFirstText(result);
expect(text).toMatch(/readonly/i);
});

it('should reject INSERT (DML)', async () => {
const result = await callTool(client, 'clickstack_sql', {
connectionId: connection._id.toString(),
sql: `INSERT INTO ${DEFAULT_DATABASE}.${DEFAULT_LOGS_TABLE} (Body) VALUES ('injected')`,
});

expect(result.isError).toBe(true);
const text = getFirstText(result);
expect(text).toMatch(/readonly/i);
});

it('should reject DROP TABLE (DDL)', async () => {
const result = await callTool(client, 'clickstack_sql', {
connectionId: connection._id.toString(),
sql: `DROP TABLE IF EXISTS ${DEFAULT_DATABASE}.${DEFAULT_LOGS_TABLE}`,
});

expect(result.isError).toBe(true);
const text = getFirstText(result);
expect(text).toMatch(/readonly/i);
});

it('should allow SELECT (read-only query)', async () => {
const result = await callTool(client, 'clickstack_sql', {
connectionId: connection._id.toString(),
sql: 'SELECT 1 AS value',
});

expect(result.isError).toBeFalsy();
});
});

describe('max_execution_time enforcement', () => {
it('should kill a query that exceeds max_execution_time', async () => {
// Test directly with ClickhouseClient to use a short timeout (1s)
// instead of waiting for the full 30s MCP default.
const clickhouseClient = new ClickhouseClient({
host: config.CLICKHOUSE_HOST,
username: config.CLICKHOUSE_USER,
password: config.CLICKHOUSE_PASSWORD,
requestTimeout: 5_000,
});

await expect(
clickhouseClient.query({
query: 'SELECT sleep(3)',
format: 'JSON',
clickhouse_settings: {
max_execution_time: 1,
},
}),
).rejects.toThrow(/TIMEOUT_EXCEEDED|timeout/i);
});
});

describe('settings propagation via clickstack_sql', () => {
it('should propagate max_execution_time=30 to ClickHouse', async () => {
const result = await callTool(client, 'clickstack_sql', {
connectionId: connection._id.toString(),
sql: "SELECT getSetting('max_execution_time') AS value",
});

expect(result.isError).toBeFalsy();
const parsed = JSON.parse(getFirstText(result));
expect(parsed.result?.data?.[0]?.value).toBe(30);
});

it('should propagate max_result_rows=100000 to ClickHouse', async () => {
const result = await callTool(client, 'clickstack_sql', {
connectionId: connection._id.toString(),
sql: "SELECT getSetting('max_result_rows') AS value",
});

expect(result.isError).toBeFalsy();
const parsed = JSON.parse(getFirstText(result));
expect(parsed.result?.data?.[0]?.value).toBe(100000);
});

it('should propagate readonly=1 to ClickHouse', async () => {
const result = await callTool(client, 'clickstack_sql', {
connectionId: connection._id.toString(),
sql: "SELECT getSetting('readonly') AS value",
});

expect(result.isError).toBeFalsy();
const parsed = JSON.parse(getFirstText(result));
expect(parsed.result?.data?.[0]?.value).toBe(1);
});
});
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
});
26 changes: 26 additions & 0 deletions packages/api/src/mcp/tools/query/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,22 @@ import { trimToolResponse } from '@/utils/trimToolResponse';
import type { ExternalDashboardTileWithId } from '@/utils/zod';
import { externalDashboardTileSchemaWithId } from '@/utils/zod';

// ─── Safety limits ───────────────────────────────────────────────────────────

/** ClickHouse settings applied to all MCP query-tool executions. */
const MCP_CLICKHOUSE_SETTINGS = {
max_execution_time: 30,
max_result_rows: '100000',
readonly: 1,
} as const;

/**
* HTTP request timeout for MCP query-tool ClickHouse clients.
* Set slightly above max_execution_time so ClickHouse can return a clean
* timeout error before the HTTP connection is aborted.
*/
const MCP_REQUEST_TIMEOUT = 32_000; // 30s query limit + 2s buffer

// ─── Where merging ───────────────────────────────────────────────────────────

export interface MergeWhereResult<T> {
Expand Down Expand Up @@ -243,6 +259,7 @@ export async function runConfigTile(
host: connection.host,
username: connection.username,
password: connection.password,
requestTimeout: MCP_REQUEST_TIMEOUT,
});

const isSearch = builderConfig.displayType === DisplayType.Search;
Expand Down Expand Up @@ -295,6 +312,7 @@ export async function runConfigTile(
config: chartConfig,
metadata,
querySettings: source.querySettings,
opts: { clickhouse_settings: MCP_CLICKHOUSE_SETTINGS },
});
return formatQueryResult(result);
} catch (e) {
Expand Down Expand Up @@ -349,6 +367,7 @@ export async function runConfigTile(
host: connection.host,
username: connection.username,
password: connection.password,
requestTimeout: MCP_REQUEST_TIMEOUT,
});

const chartConfig = {
Expand All @@ -363,6 +382,7 @@ export async function runConfigTile(
config: chartConfig,
metadata,
querySettings: undefined,
opts: { clickhouse_settings: MCP_CLICKHOUSE_SETTINGS },
});
return formatQueryResult(result);
} catch (e) {
Expand Down Expand Up @@ -419,5 +439,11 @@ export function errorHint(msg: string): string | null {
'The result row count is too large to serialize back to the agent.'
);
}
if (/TOO_MANY_ROWS_OR_BYTES|RESULT_IS_TOO_LARGE/i.test(msg)) {
return (
'The query returned more than 100,000 rows. ' +
'Add a LIMIT, narrow the time range, or add filters to reduce the result set.'
);
}
return null;
}
Loading