Marginalia MCP Server

import { z } from "zod"; import { QueryResult, AnalysisQuery, QueryCalculation, } from "../types/query.js"; import { QueryToolSchema, ColumnAnalysisSchema } from "../types/schema.js"; import { HoneycombConfig } from "../types/config.js"; import { HoneycombError } from "../utils/errors.js"; import { Column } from "../types/column.js"; import { Dataset } from "../types/api.js"; import { SLO, SLODetailedResponse } from "../types/slo.js"; import { TriggerResponse } from "../types/trigger.js"; import { HoneycombEnvironment } from "../types/config.js"; import { QueryOptions } from "../types/api.js"; import { Config } from "../config.js"; export class HoneycombAPI { private environments: Map<string, { apiKey: string }>; constructor(config: Config) { this.environments = new Map( config.environments.map(env => [env.name, { apiKey: env.apiKey }]) ); } getEnvironments(): string[] { return Array.from(this.environments.keys()); } private getApiKey(environment: string): string { const env = this.environments.get(environment); if (!env) { throw new Error( `Unknown environment: "${environment}". Available environments: ${Array.from(this.environments.keys()).join(", ")}` ); } return env.apiKey; } private async request<T>( environment: string, path: string, options: RequestInit & { params?: Record<string, any> } = {}, ): Promise<T> { const apiKey = this.getApiKey(environment); const { params, ...requestOptions } = options; let url = `https://api.honeycomb.io${path}`; if (params) { const searchParams = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { if (value !== undefined) { searchParams.append(key, String(value)); } }); url += `?${searchParams.toString()}`; } const response = await fetch(url, { ...requestOptions, headers: { "X-Honeycomb-Team": apiKey, "Content-Type": "application/json", ...options.headers, }, }); if (!response.ok) { throw new HoneycombError( response.status, `Honeycomb API error: ${response.statusText}`, ); } return response.json() as Promise<T>; } // Dataset methods async getDataset(environment: string, datasetSlug: string): Promise<Dataset> { const apiKey = this.getApiKey(environment); return this.request(environment, `/1/datasets/${datasetSlug}`); } async listDatasets(environment: string): Promise<Dataset[]> { const apiKey = this.getApiKey(environment); return this.request(environment, "/1/datasets"); } // Query methods async createQuery( environment: string, datasetSlug: string, query: AnalysisQuery, ): Promise<{ id: string }> { const apiKey = this.getApiKey(environment); return this.request<{ id: string }>( environment, `/1/queries/${datasetSlug}`, { method: "POST", body: JSON.stringify(query), }, ); } async createQueryResult( environment: string, datasetSlug: string, queryId: string, ): Promise<{ id: string }> { const apiKey = this.getApiKey(environment); return this.request<{ id: string }>( environment, `/1/query_results/${datasetSlug}`, { method: "POST", body: JSON.stringify({ query_id: queryId }), }, ); } async getQueryResults( environment: string, datasetSlug: string, queryResultId: string, includeSeries: boolean = false, ): Promise<QueryResult> { const apiKey = this.getApiKey(environment); const response = await this.request<QueryResult>( environment, `/1/query_results/${datasetSlug}/${queryResultId}`, { params: { include_series: includeSeries, }, }, ); if (!includeSeries && response.data) { const { series, ...rest } = response.data; response.data = rest; } return response; } async queryAndWaitForResults( environment: string, datasetSlug: string, query: AnalysisQuery, maxAttempts = 10, options: QueryOptions = {}, ): Promise<QueryResult> { const apiKey = this.getApiKey(environment); const defaultLimit = 100; const queryWithLimit = { ...query, limit: query.limit || options.limit || defaultLimit, }; const queryResponse = await this.createQuery( environment, datasetSlug, queryWithLimit, ); const queryId = queryResponse.id; const queryResult = await this.createQueryResult( environment, datasetSlug, queryId, ); const queryResultId = queryResult.id; let attempts = 0; while (attempts < maxAttempts) { const results = await this.getQueryResults( environment, datasetSlug, queryResultId, options.includeSeries, ); if (results.complete) { return results; } attempts++; await new Promise((resolve) => setTimeout(resolve, 1000)); } throw new Error("Query timed out waiting for results"); } // Column methods async getColumns( environment: string, datasetSlug: string, ): Promise<Column[]> { const apiKey = this.getApiKey(environment); return this.request(environment, `/1/columns/${datasetSlug}`); } async getColumnByName( environment: string, datasetSlug: string, keyName: string, ): Promise<Column> { const apiKey = this.getApiKey(environment); return this.request( environment, `/1/columns/${datasetSlug}?key_name=${encodeURIComponent(keyName)}`, ); } async getVisibleColumns( environment: string, datasetSlug: string, ): Promise<Column[]> { const apiKey = this.getApiKey(environment); const columns = await this.getColumns(environment, datasetSlug); return columns.filter((column) => !column.hidden); } async runAnalysisQuery( environment: string, datasetSlug: string, params: z.infer<typeof QueryToolSchema>, ) { const apiKey = this.getApiKey(environment); const query: AnalysisQuery = { calculations: params.calculations, breakdowns: params.breakdowns || [], filters: params.filters, filter_combination: params.filter_combination, time_range: params.time_range || 3600, orders: params.orders, limit: params.limit, start_time: params.start_time, end_time: params.end_time, granularity: params.granularity, having: params.having, }; try { const results = await this.queryAndWaitForResults( environment, datasetSlug, query, ); return { data: { results: results.data?.results || [], series: results.data?.series || [], }, links: results.links, }; } catch (error) { throw new Error( `Analysis query failed: ${error instanceof Error ? error.message : "Unknown error"}`, ); } } async analyzeColumn( environment: string, datasetSlug: string, params: z.infer<typeof ColumnAnalysisSchema>, ) { const apiKey = this.getApiKey(environment); const column = await this.getColumnByName( environment, datasetSlug, params.column, ); const query: AnalysisQuery = { calculations: [{ op: "COUNT" }], breakdowns: [params.column], time_range: params.timeRange || 3600, orders: [ { op: "COUNT", order: "descending", }, ], limit: 10, }; if (column.type === "integer" || column.type === "float") { const numericCalculations: QueryCalculation[] = [ { op: "AVG", column: params.column }, { op: "P95", column: params.column }, { op: "MAX", column: params.column }, { op: "MIN", column: params.column }, ]; query.calculations.push(...numericCalculations); } try { const results = await this.queryAndWaitForResults( environment, datasetSlug, query, ); return { data: { results: results.data?.results || [], series: results.data?.series || [], }, links: results.links, }; } catch (error) { throw new Error( `Column analysis failed: ${error instanceof Error ? error.message : "Unknown error"}`, ); } } async getSLOs(environment: string, datasetSlug: string): Promise<SLO[]> { const apiKey = this.getApiKey(environment); return this.request<SLO[]>(environment, `/1/slos/${datasetSlug}`); } async getSLO( environment: string, datasetSlug: string, sloId: string, ): Promise<SLODetailedResponse> { const apiKey = this.getApiKey(environment); return this.request<SLODetailedResponse>( environment, `/1/slos/${datasetSlug}/${sloId}`, { params: { detailed: true } }, ); } async getTriggers( environment: string, datasetSlug: string, ): Promise<TriggerResponse[]> { const apiKey = this.getApiKey(environment); return this.request<TriggerResponse[]>( environment, `/1/triggers/${datasetSlug}`, ); } async getTrigger( environment: string, datasetSlug: string, triggerId: string, ): Promise<TriggerResponse> { const apiKey = this.getApiKey(environment); return this.request<TriggerResponse>( environment, `/1/triggers/${datasetSlug}/${triggerId}`, ); } }