-
Notifications
You must be signed in to change notification settings - Fork 27
feat(mcp-server): add prometheus metrics endpoint #305
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@transloadit/mcp-server": minor | ||
| --- | ||
|
|
||
| Add Prometheus-compatible metrics endpoint support. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,9 @@ import { randomUUID } from 'node:crypto' | |
| import type { IncomingMessage, ServerResponse } from 'node:http' | ||
| import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' | ||
| import type { SevLogger } from '@transloadit/sev-logger' | ||
| import { isBasicAuthorized, normalizePath, parsePathname } from './http-helpers.ts' | ||
| import { createMcpRequestHandler } from './http-request-handler.ts' | ||
| import { getMetrics, getMetricsContentType } from './metrics.ts' | ||
| import type { TransloaditMcpServerOptions } from './server.ts' | ||
| import { createTransloaditMcpServer } from './server.ts' | ||
|
|
||
|
|
@@ -12,6 +14,8 @@ export type TransloaditMcpHttpOptions = TransloaditMcpServerOptions & { | |
| enableDnsRebindingProtection?: boolean | ||
| mcpToken?: string | ||
| path?: string | ||
| metricsPath?: string | false | ||
| metricsAuth?: { username: string; password: string } | ||
| sessionIdGenerator?: (() => string) | undefined | ||
| logger?: SevLogger | ||
| } | ||
|
|
@@ -38,12 +42,47 @@ export const createTransloaditMcpHttpHandler = async ( | |
|
|
||
| await server.connect(transport) | ||
|
|
||
| const handler = createMcpRequestHandler(transport, { | ||
| const expectedPath = options.path ?? defaultPath | ||
| const metricsPath = | ||
| options.metricsPath === false ? undefined : normalizePath(options.metricsPath ?? '/metrics') | ||
| const metricsAuth = options.metricsAuth | ||
|
|
||
| const mcpHandler = createMcpRequestHandler(transport, { | ||
| allowedOrigins: options.allowedOrigins, | ||
| mcpToken: options.mcpToken, | ||
| path: { expectedPath: options.path ?? defaultPath }, | ||
| path: { expectedPath }, | ||
| logger: options.logger, | ||
| redactSecrets: [options.mcpToken, options.authKey, options.authSecret], | ||
| }) | ||
|
|
||
| const handler = (async (req, res) => { | ||
| if (metricsPath) { | ||
| const pathname = normalizePath(parsePathname(req.url, expectedPath)) | ||
| if (pathname === metricsPath) { | ||
| if (metricsAuth && !isBasicAuthorized(req, metricsAuth)) { | ||
| res.statusCode = 401 | ||
| res.setHeader('WWW-Authenticate', 'Basic realm="metrics"') | ||
| res.end('Unauthorized') | ||
| return | ||
| } | ||
| if (req.method !== 'GET' && req.method !== 'HEAD') { | ||
|
Comment on lines
+58
to
+68
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The metrics branch short‑circuits before Useful? React with 👍 / 👎. |
||
| res.statusCode = 405 | ||
| res.end('Method Not Allowed') | ||
| return | ||
| } | ||
|
|
||
| res.statusCode = 200 | ||
| res.setHeader('Content-Type', getMetricsContentType()) | ||
| if (req.method === 'HEAD') { | ||
| res.end() | ||
| return | ||
| } | ||
| res.end(await getMetrics()) | ||
| return | ||
| } | ||
| } | ||
|
|
||
| await mcpHandler(req, res) | ||
| }) as TransloaditMcpHttpHandler | ||
|
|
||
| handler.close = async () => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import { collectDefaultMetrics, Registry } from 'prom-client' | ||
|
|
||
| const registry = new Registry() | ||
| let defaultsStarted = false | ||
|
|
||
| const ensureDefaults = (): void => { | ||
| if (defaultsStarted) return | ||
| collectDefaultMetrics({ register: registry }) | ||
| defaultsStarted = true | ||
| } | ||
|
|
||
| export const getMetrics = (): Promise<string> => { | ||
| ensureDefaults() | ||
| return registry.metrics() | ||
| } | ||
|
|
||
| export const getMetricsContentType = (): string => { | ||
| ensureDefaults() | ||
| return registry.contentType | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import { describe, expect, it } from 'vitest' | ||
| import { startHttpServer } from './http-server.ts' | ||
|
|
||
| describe('metrics', () => { | ||
| it('exposes prometheus metrics', async () => { | ||
| const { url, close } = await startHttpServer() | ||
|
|
||
| try { | ||
| const metricsUrl = new URL(url) | ||
| metricsUrl.pathname = '/metrics' | ||
|
|
||
| const response = await fetch(metricsUrl) | ||
| expect(response.status).toBe(200) | ||
| const contentType = response.headers.get('content-type') | ||
| expect(contentType).toContain('text/plain') | ||
|
|
||
| const body = await response.text() | ||
| expect(body).toContain('process_cpu_user_seconds_total') | ||
| } finally { | ||
| await close() | ||
| } | ||
| }) | ||
|
|
||
| it('requires basic auth when configured', async () => { | ||
| const { url, close } = await startHttpServer({ | ||
| metricsAuth: { username: 'metrics-user', password: 'metrics-pass' }, | ||
| }) | ||
|
|
||
| try { | ||
| const metricsUrl = new URL(url) | ||
| metricsUrl.pathname = '/metrics' | ||
|
|
||
| const unauthorized = await fetch(metricsUrl) | ||
| expect(unauthorized.status).toBe(401) | ||
|
|
||
| const wrongAuth = await fetch(metricsUrl, { | ||
| headers: { | ||
| Authorization: `Basic ${Buffer.from('wrong:creds').toString('base64')}`, | ||
| }, | ||
| }) | ||
| expect(wrongAuth.status).toBe(401) | ||
|
|
||
| const ok = await fetch(metricsUrl, { | ||
| headers: { | ||
| Authorization: `Basic ${Buffer.from('metrics-user:metrics-pass').toString('base64')}`, | ||
| }, | ||
| }) | ||
| expect(ok.status).toBe(200) | ||
| const body = await ok.text() | ||
| expect(body).toContain('process_cpu_user_seconds_total') | ||
| } finally { | ||
| await close() | ||
| } | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Express router adds
GET/HEADhandlers formetricsPathwithout any of the MCP auth/CORS checks, so a configuredmcpTokendoes not protect the metrics route. If this router is mounted on a public host, anyone can scrape runtime/process metrics. Consider applying the same auth check as the MCP handler (or a dedicated metrics token) before responding.Useful? React with 👍 / 👎.