mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
## Summary Adds an MCP (Model Context Protocol) server to the HyperDX API, enabling AI assistants (Claude, Cursor, OpenCode, etc.) to query observability data, manage dashboards, and explore data sources directly via standardized tool calls. Key changes: - **MCP server** (`packages/api/src/mcp/`) — Streamable HTTP transport at `/api/mcp`, authenticated via Personal API Access Key - **Tools** — `hyperdx_list_sources`, `hyperdx_query`, `hyperdx_get_dashboard`, `hyperdx_save_dashboard`, `hyperdx_delete_dashboard`, `hyperdx_query_tile` - **Dashboard prompts** — Detailed prompt templates that guide LLMs in generating valid, high-quality dashboards - **Shared logic** — Refactored dashboard validation/transformation out of the external API router into reusable utils (`packages/api/src/routers/external-api/v2/utils/dashboards.ts`) - **Documentation** — `MCP.md` with setup instructions for Claude Code, OpenCode, Cursor, MCP Inspector, and other clients - **Tests** — Unit tests for dashboard tools, query tools, tracing, and response trimming ### Screenshots https://github.com/user-attachments/assets/8c5aa582-c79e-47e0-8f75-e03feabdf8a6 ### How to test locally 1. Start the dev stack: `yarn dev` 2. Connect an MCP client (e.g. MCP Inspector): ```bash cd packages/api && yarn dev:mcp ``` Then configure the inspector: - **Transport Type:** Streamable HTTP - **URL:** `http://localhost:8080/api/mcp` - **Header:** `Authorization: Bearer <your-personal-access-key>` - Click **Connect** 3. Alternatively, connect via Claude Code or OpenCode: ```bash claude mcp add --transport http hyperdx http://localhost:8080/api/mcp \ --header "Authorization: Bearer <your-personal-access-key>" ``` 4. Try listing sources, querying data, or creating/updating a dashboard through the connected AI assistant. 5. Run unit tests: ```bash cd packages/api && yarn ci:unit ``` ### References - Linear Issue: HDX-3710
109 lines
3.6 KiB
TypeScript
109 lines
3.6 KiB
TypeScript
import logger from '@/utils/logger';
|
|
|
|
/**
|
|
* Trims large data structures to prevent "Request Entity Too Large" errors
|
|
* when multiple tool calls accumulate data in the conversation history.
|
|
*/
|
|
export function trimToolResponse(data: any, maxSize: number = 50000): any {
|
|
const serialized = JSON.stringify(data);
|
|
|
|
// If data is within acceptable size, return as-is
|
|
if (serialized.length <= maxSize) {
|
|
return data;
|
|
}
|
|
|
|
logger.warn(
|
|
`Tool response too large, trimming data. Original Size: ${serialized.length}, Max Size: ${maxSize}`,
|
|
);
|
|
|
|
// Handle different data structures
|
|
if (Array.isArray(data)) {
|
|
return trimArray(data, maxSize);
|
|
}
|
|
|
|
if (typeof data === 'object' && data !== null) {
|
|
return trimObject(data, maxSize);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
function trimArray(arr: any[], maxSize: number): any[] {
|
|
// Keep reducing array size until it fits
|
|
let result = [...arr];
|
|
let resultSize = JSON.stringify(result).length;
|
|
|
|
while (resultSize > maxSize && result.length > 10) {
|
|
// Keep at least 10 items
|
|
const newLength = Math.max(10, Math.floor(result.length * 0.7));
|
|
result = result.slice(0, newLength);
|
|
resultSize = JSON.stringify(result).length;
|
|
}
|
|
|
|
// If we're still over budget (e.g. a single item exceeds maxSize), truncate
|
|
// individual oversized items so the array itself stays within the limit.
|
|
if (resultSize > maxSize) {
|
|
result = result.map(item => {
|
|
const itemStr = JSON.stringify(item);
|
|
if (itemStr.length > maxSize) {
|
|
logger.info(
|
|
`Trimming oversized array item (${itemStr.length} bytes > ${maxSize} limit)`,
|
|
);
|
|
if (typeof item === 'object' && item !== null) {
|
|
return trimObject(item, maxSize);
|
|
}
|
|
// Scalar that is itself too large — return a truncation marker
|
|
return { __hdx_trimmed: true, originalSize: itemStr.length };
|
|
}
|
|
return item;
|
|
});
|
|
}
|
|
|
|
if (result.length < arr.length) {
|
|
logger.info(`Trimmed array from ${arr.length} to ${result.length} items`);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// Keys in trimObject come exclusively from Object.entries() on internal tool
|
|
// response data — never from user-supplied HTTP input — so bracket-notation
|
|
// writes are not an injection risk; see inline eslint-disable comments below.
|
|
function trimObject(obj: any, maxSize: number): any {
|
|
const entries = Object.entries(obj);
|
|
if (entries.length === 0) return obj;
|
|
|
|
const result: any = {};
|
|
|
|
// Give each key an equal share of the budget so that no single large value
|
|
// crowds out the rest (e.g. a large array at key[0] eating all the budget
|
|
// before key[1] gets a chance to appear).
|
|
const perKeyBudget = Math.floor(maxSize / entries.length);
|
|
let trimmed = false;
|
|
|
|
for (const [key, value] of entries) {
|
|
const valueStr = JSON.stringify(value);
|
|
|
|
if (valueStr.length <= perKeyBudget) {
|
|
result[key] = value; // eslint-disable-line security/detect-object-injection
|
|
} else {
|
|
logger.info(
|
|
`Trimming oversized object value at key "${key}" (${valueStr.length} bytes > ${perKeyBudget} per-key budget)`,
|
|
);
|
|
if (Array.isArray(value)) {
|
|
result[key] = trimArray(value, perKeyBudget); // eslint-disable-line security/detect-object-injection
|
|
} else if (typeof value === 'object' && value !== null) {
|
|
result[key] = trimObject(value, perKeyBudget); // eslint-disable-line security/detect-object-injection
|
|
} else {
|
|
result[key] = { __hdx_trimmed: true, originalSize: valueStr.length }; // eslint-disable-line security/detect-object-injection
|
|
}
|
|
trimmed = true;
|
|
}
|
|
}
|
|
|
|
if (trimmed) {
|
|
result.__hdx_trimmed = true;
|
|
}
|
|
|
|
return result;
|
|
}
|