diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..1785bbd --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,47 @@ +name: Build and Publish Docker Image + +on: + push: + tags: + - 'v*' + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: serkan-ozal/jaeger-mcp-server + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=ref,event=branch + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2fcae37 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM node:18-slim + +# Install supergateway globally +RUN npm install -g supergateway + +# Set up jaeger-mcp +WORKDIR /app + +# Copy jaeger-mcp package files +COPY package*.json ./ +COPY scripts/ ./scripts/ +COPY protos/ ./protos/ + +# Install dependencies +RUN npm ci + +# Copy source files +COPY tsconfig.json ./ +COPY src/ ./src/ + +# Build the application +RUN npm run build && chmod +x ./dist/index.js + +# Environment variables +ENV NODE_ENV=production +ENV JAEGER_MCP_SSE_PORT=8000 + +# Expose port +EXPOSE ${JAEGER_MCP_SSE_PORT} + +# Run supergateway with jaeger-mcp +ENTRYPOINT ["sh", "-c", "supergateway --stdio 'node /app/dist/index.js' --port ${JAEGER_MCP_SSE_PORT}"] diff --git a/README.md b/README.md index 344a208..16fbc43 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ like VS Code, Claude, Cursor, Windsurf Github Copilot via the `jaeger-mcp-server - `JAEGER_PORT`: HTTP or gRPC API port of the Jaeger instance to access. The default value is `16685` for the gRPC API and `16686` for the HTTP API. - `JAEGER_AUTHORIZATION_HEADER`: `Authorization` HTTP header to be added into the requests for querying traces over Jaeger API (for ex. `Basic `) - `JAEGER_PROTOCOL`: API protocol of the Jaeger instance to access. Valid values are `GRPC` and `HTTP`. The default value is `GRPC`. Valid +- `JAEGER_USE_DEFAULT_PORT`: If `false`, the Jaeger HTTP client won't attempt to use a port when `JAEGER_PORT` is not set. Default is `true`. +- `LOG_LEVEL`: Sets the log level from one of [`debug`, `info`, `warn`, `error`, `none`]. Default is `none`. ## Components @@ -105,7 +107,7 @@ like VS Code, Claude, Cursor, Windsurf Github Copilot via the `jaeger-mcp-server "stringAttribute": "str", "integerAttribute": 123, "doubleAttribute": 123.456, - "booleanAttribute": true, + "booleanAttribute": true } ``` - `startTimeMin`: diff --git a/src/client/jaeger-grpc-client.ts b/src/client/jaeger-grpc-client.ts index 7390c1f..09d3dfa 100644 --- a/src/client/jaeger-grpc-client.ts +++ b/src/client/jaeger-grpc-client.ts @@ -5,6 +5,8 @@ import { FindTracesResponse, GetOperationsRequest, GetOperationsResponse, + GetServiceGraphRequest, + GetServiceGraphResponse, GetServicesRequest, GetServicesResponse, GetTraceRequest, @@ -391,4 +393,8 @@ export class JaegerGrpcClient implements JaegerClient { return this._handleError(err); } } + + async getServiceGraph(request: GetServiceGraphRequest): Promise { + return this._handleError('getServiceGraph not supported by gRPC'); + } } diff --git a/src/client/jaeger-http-client.ts b/src/client/jaeger-http-client.ts index 6f4833d..5a28ff8 100644 --- a/src/client/jaeger-http-client.ts +++ b/src/client/jaeger-http-client.ts @@ -3,6 +3,8 @@ import { FindTracesResponse, GetOperationsRequest, GetOperationsResponse, + GetServiceGraphRequest, + GetServiceGraphResponse, GetServicesRequest, GetServicesResponse, GetTraceRequest, @@ -22,19 +24,18 @@ const HTTP_STATUS_CODE_NOT_FOUND: number = 404; export class JaegerHttpClient implements JaegerClient { private readonly url: string; - private readonly port: number; private readonly authorizationHeader: string | undefined; constructor(clientConfigurations: ClientConfigurations) { - this.url = JaegerHttpClient._normalizeUrl(clientConfigurations.url); - this.port = JaegerHttpClient._normalizePort( - this.url, - clientConfigurations.port + this.url = JaegerHttpClient._normalizeUrl( + clientConfigurations.url, + clientConfigurations.port, + clientConfigurations.allowDefaultPort ); this.authorizationHeader = clientConfigurations.authorizationHeader; } - private static _normalizeUrl(url: string, port?: number): string { + private static _normalizeUrl(url: string, port?: number, allowDefaultPort?: boolean): string { const schemaIdx: number = url.indexOf(URL_SCHEMA_SEPARATOR); if (schemaIdx < 0) { if (port === SECURE_URL_PORT) { @@ -43,22 +44,22 @@ export class JaegerHttpClient implements JaegerClient { url = `${INSECURE_URL_SCHEMA}${url}`; } } - return url; - } - private static _normalizePort( - normalizedUrl: string, - port?: number - ): number { - if (normalizedUrl.startsWith(SECURE_URL_SCHEMA)) { - return port || SECURE_URL_PORT; + if (port) { + url = `${url}:${port}` + } else if (allowDefaultPort) { + port = url.startsWith(SECURE_URL_SCHEMA) + ? SECURE_URL_PORT + : DEFAULT_PORT; + url = `${url}:${port}`; } - return port || DEFAULT_PORT; + + return url; } private async _get(path: string, params?: any): Promise { const response: AxiosResponse = await axios.get( - `${this.url}:${this.port}/${path}`, + `${this.url}${path}`, { params, headers: { @@ -239,4 +240,19 @@ export class JaegerHttpClient implements JaegerClient { return this._handleError(err); } } + + async getServiceGraph(request: GetServiceGraphRequest): Promise { + try { + const httpResponse: any = await this._get('/api/dependencies', { + 'endTs': request.endTS, + 'lookback': request.lookback, + }); + + return { + graphEdges: httpResponse.data + } as GetServiceGraphResponse; + } catch (err: any) { + return this._handleError(err); + } + } } diff --git a/src/client/types.ts b/src/client/types.ts index 2dee519..ad76f1a 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -3,6 +3,8 @@ import { FindTracesResponse, GetOperationsRequest, GetOperationsResponse, + GetServiceGraphRequest, + GetServiceGraphResponse, GetServicesRequest, GetServicesResponse, GetTraceRequest, @@ -13,6 +15,7 @@ export type ClientConfigurations = { url: string; port?: number; authorizationHeader?: string; + allowDefaultPort?: boolean; }; export interface JaegerClient { @@ -22,4 +25,5 @@ export interface JaegerClient { ): Promise; getTrace(request: GetTraceRequest): Promise; findTraces(request: FindTracesRequest): Promise; + getServiceGraph(request: GetServiceGraphRequest): Promise } diff --git a/src/domain/commons.ts b/src/domain/commons.ts index ae64d9f..6bbd434 100644 --- a/src/domain/commons.ts +++ b/src/domain/commons.ts @@ -86,3 +86,9 @@ export type ResourceSpans = { scopeSpans: ScopeSpans[]; schemaUrl?: string; }; + +export type ServiceGraphEdge = { + parent: string; + child: string; + callCount: number; +}; diff --git a/src/domain/requests.ts b/src/domain/requests.ts index 30c0613..b2ab6b3 100644 --- a/src/domain/requests.ts +++ b/src/domain/requests.ts @@ -29,3 +29,8 @@ export type TraceQueryParameters = { export type FindTracesRequest = { query: TraceQueryParameters; }; + +export type GetServiceGraphRequest = { + endTS: number; + lookback: number; +}; diff --git a/src/domain/responses.ts b/src/domain/responses.ts index 5063ab8..8bd4165 100644 --- a/src/domain/responses.ts +++ b/src/domain/responses.ts @@ -1,4 +1,4 @@ -import { ResourceSpans, SpanKind } from './commons'; +import { ResourceSpans, ServiceGraphEdge, SpanKind } from './commons'; export type Operation = { name: string; @@ -20,3 +20,7 @@ export type GetTraceResponse = { export type FindTracesResponse = { resourceSpans: ResourceSpans[]; }; + +export type GetServiceGraphResponse = { + graphEdges: ServiceGraphEdge[]; +}; diff --git a/src/logger.ts b/src/logger.ts index 955a2e3..7b6a5e0 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -4,9 +4,33 @@ const BANNER_TEXT = '[JAEGER-MCP-SERVER]'; const BANNER_BG_COLOR = '#628816'; const BANNER_TEXT_COLOR = '#5ECAE0'; -const DISABLED = true; +export enum LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, + NONE = 4 +} + +export function parseLogLevel(levelStr?: string): LogLevel { + if (!levelStr) { + return LogLevel.NONE; + } + + const normalized = levelStr.toLowerCase().trim(); + + switch (normalized) { + case 'debug': return LogLevel.DEBUG; + case 'info': return LogLevel.INFO; + case 'warn': return LogLevel.WARN; + case 'error': return LogLevel.ERROR; + case 'none': + default: return LogLevel.NONE; + } +} -let debugEnabled = false; +// Initialize log level from environment +let currentLogLevel = parseLogLevel(process.env.LOG_LEVEL); function _timeAsString(): string { const date: Date = new Date(); @@ -20,7 +44,7 @@ function _timeAsString(): string { } function _normalizeArgs(...args: any[]): any[] { - if (isDebugEnabled()) { + if (currentLogLevel === LogLevel.DEBUG) { return args; } else { return (args || []).map((arg) => { @@ -39,34 +63,34 @@ function _normalizeArgs(...args: any[]): any[] { } } -export function isDebugEnabled(): boolean { - return debugEnabled; +export function getLogLevel(): LogLevel { + return currentLogLevel; } -export function setDebugEnabled(enabled: boolean): void { - debugEnabled = enabled; +export function setLogLevel(level: LogLevel): void { + currentLogLevel = level; } export function debug(...args: any[]): void { - if (DISABLED) { + if (currentLogLevel > LogLevel.DEBUG) { return; } - if (isDebugEnabled()) { - console.debug( - chalk.bgHex(BANNER_BG_COLOR).hex(BANNER_TEXT_COLOR)(BANNER_TEXT), - _timeAsString(), - '|', - chalk.blue('DEBUG'), - '-', - ..._normalizeArgs(...args) - ); - } + + console.debug( + chalk.bgHex(BANNER_BG_COLOR).hex(BANNER_TEXT_COLOR)(BANNER_TEXT), + _timeAsString(), + '|', + chalk.blue('DEBUG'), + '-', + ..._normalizeArgs(...args) + ); } export function info(...args: any[]): void { - if (DISABLED) { + if (currentLogLevel > LogLevel.INFO) { return; } + console.info( chalk.bgHex(BANNER_BG_COLOR).hex(BANNER_TEXT_COLOR)(BANNER_TEXT), _timeAsString(), @@ -78,9 +102,10 @@ export function info(...args: any[]): void { } export function warn(...args: any[]): void { - if (DISABLED) { + if (currentLogLevel > LogLevel.WARN) { return; } + console.warn( chalk.bgHex(BANNER_BG_COLOR).hex(BANNER_TEXT_COLOR)(BANNER_TEXT), _timeAsString(), @@ -92,9 +117,10 @@ export function warn(...args: any[]): void { } export function error(...args: any[]): void { - if (DISABLED) { + if (currentLogLevel > LogLevel.ERROR) { return; } + console.error( chalk.bgHex(BANNER_BG_COLOR).hex(BANNER_TEXT_COLOR)(BANNER_TEXT), _timeAsString(), diff --git a/src/server.ts b/src/server.ts index 6891a58..66a08d0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,6 @@ import { createClient, JaegerClient } from './client'; import * as logger from './logger'; -import { tools, Tool, ToolInput } from './tools/'; +import { Tool, ToolInput, getTools } from './tools/'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; @@ -21,6 +21,7 @@ function _createJaegerClient(): JaegerClient { ? parseInt(process.env.JAEGER_PORT) : undefined, authorizationHeader: process.env.JAEGER_AUTHORIZATION_HEADER, + allowDefaultPort: process.env.JAEGER_USE_DEFAULT_PORT !== 'false' }); } @@ -64,7 +65,8 @@ export async function startServer(): Promise { }; }; - tools.forEach((t: Tool) => { + const isGRPC = !process.env.JAEGER_PROTOCOL || process.env.JAEGER_PROTOCOL.toUpperCase() === 'GRPC'; + getTools(isGRPC).forEach((t: Tool) => { logger.info(`Registering tool ${t.name} ...`); server.tool( t.name(), diff --git a/src/tools/get-service-graph.ts b/src/tools/get-service-graph.ts new file mode 100644 index 0000000..544d0fd --- /dev/null +++ b/src/tools/get-service-graph.ts @@ -0,0 +1,50 @@ +import { Tool } from './types'; +import { JaegerClient } from '../client'; +import { GetServiceGraphResponse } from '../domain'; + +import { z } from 'zod'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; + +export class GetServiceGraph implements Tool { + name(): string { + return 'get-service-graph'; + } + + description(): string { + return 'Gets the service graphs as a JSON array of graph edges represented as tuples (caller, callee, count). Internal HTTP - not officially support by Jaeger HTTP API'; + } + + paramsSchema() { + return { + endTs: z + .number() + .positive() + .describe( + '(number of milliseconds since epoch) - the end of the time interval' + ), + lookback: z + .number() + .positive() + .describe( + '(in milliseconds) - the length the time interval (i.e. start-time + lookback = end-time).' + ) + }; + } + + async handle( + server: Server, + jaegerClient: JaegerClient, + { + endTs, + lookback, + }: any, + ): Promise { + const response: GetServiceGraphResponse = await jaegerClient.getServiceGraph( + { + endTS: endTs, + lookback: lookback, + } + ); + return JSON.stringify(response.graphEdges); + } +} \ No newline at end of file diff --git a/src/tools/index.ts b/src/tools/index.ts index db364ed..673a161 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -3,6 +3,7 @@ import { GetOperations } from './get-operations'; import { GetServices } from './get-services'; import { GetTrace } from './get-trace'; import { FindTraces } from './find-traces'; +import { GetServiceGraph } from './get-service-graph'; export const tools: Tool[] = [ new GetOperations(), @@ -11,4 +12,12 @@ export const tools: Tool[] = [ new FindTraces(), ]; +export const httpOnlyTools: Tool[] = [ + new GetServiceGraph(), +]; + +export function getTools(isGRPC: boolean): Tool[] { + return isGRPC ? tools : [...tools, ...httpOnlyTools]; +} + export { Tool, ToolInput, ToolOutput, ToolParamsSchema };