hyperdx/packages/api/src/utils/trimToolResponse.ts
Brandon Pereira 9781ae6387
feat: integrate Model Context Protocol (MCP) server for dashboards & investigations (#2030)
## 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
2026-04-14 14:39:07 +00:00

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;
}