diff --git a/.changeset/mcp-server-metrics.md b/.changeset/mcp-server-metrics.md new file mode 100644 index 00000000..b84a1234 --- /dev/null +++ b/.changeset/mcp-server-metrics.md @@ -0,0 +1,5 @@ +--- +"@transloadit/mcp-server": minor +--- + +Add Prometheus-compatible metrics endpoint support. diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 279132ec..8d015db8 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -43,12 +43,21 @@ Environment: - `TRANSLOADIT_SECRET` - `TRANSLOADIT_MCP_TOKEN` - `TRANSLOADIT_ENDPOINT` (optional, default `https://api2.transloadit.com`) +- `TRANSLOADIT_MCP_METRICS_PATH` (optional, default `/metrics`) CLI: - `transloadit-mcp http --host 127.0.0.1 --port 5723 --endpoint https://api2.transloadit.com` - `transloadit-mcp http --config path/to/config.json` +## Metrics + +- Prometheus-compatible metrics are exposed at `GET /metrics` by default. +- Customize the path via `TRANSLOADIT_MCP_METRICS_PATH` or config `metricsPath`. +- Disable by setting `metricsPath: false` in the config or when creating the server/router. +- Optional basic auth via `TRANSLOADIT_MCP_METRICS_USER` + + `TRANSLOADIT_MCP_METRICS_PASSWORD` or config `metricsAuth`. + ## Input files ```ts diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index d89e9d59..c4513d42 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -42,6 +42,7 @@ "@transloadit/node": "^4.3.1", "@transloadit/sev-logger": "^0.1.9", "express": "^4.21.2", + "prom-client": "^15.1.3", "zod": "^4.0.0" }, "devDependencies": { diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts index 6b6d764d..7fbdcb9e 100644 --- a/packages/mcp-server/src/cli.ts +++ b/packages/mcp-server/src/cli.ts @@ -18,6 +18,9 @@ Environment: TRANSLOADIT_SECRET TRANSLOADIT_MCP_TOKEN TRANSLOADIT_ENDPOINT + TRANSLOADIT_MCP_METRICS_PATH + TRANSLOADIT_MCP_METRICS_USER + TRANSLOADIT_MCP_METRICS_PASSWORD `) } @@ -106,6 +109,17 @@ const main = async (): Promise => { const host = (config.host ?? fileConfig.host ?? '127.0.0.1') as string const port = Number(config.port ?? fileConfig.port ?? 5723) const path = (fileConfig.path as string | undefined) ?? '/mcp' + const metricsPath = + (fileConfig.metricsPath as string | undefined) ?? process.env.TRANSLOADIT_MCP_METRICS_PATH + const metricsAuthConfig = fileConfig.metricsAuth as + | { username?: string; password?: string } + | undefined + const metricsUser = (fileConfig.metricsUser ?? + metricsAuthConfig?.username ?? + process.env.TRANSLOADIT_MCP_METRICS_USER) as string | undefined + const metricsPassword = (fileConfig.metricsPassword ?? + metricsAuthConfig?.password ?? + process.env.TRANSLOADIT_MCP_METRICS_PASSWORD) as string | undefined const endpoint = (config.endpoint ?? fileConfig.endpoint ?? process.env.TRANSLOADIT_ENDPOINT) as | string | undefined @@ -126,6 +140,11 @@ const main = async (): Promise => { allowedHosts: fileConfig.allowedHosts as string[] | undefined, enableDnsRebindingProtection: fileConfig.enableDnsRebindingProtection as boolean | undefined, path, + metricsPath, + metricsAuth: + metricsUser && metricsPassword + ? { username: metricsUser, password: metricsPassword } + : undefined, logger, }) diff --git a/packages/mcp-server/src/express.ts b/packages/mcp-server/src/express.ts index 69da3464..acaa5e16 100644 --- a/packages/mcp-server/src/express.ts +++ b/packages/mcp-server/src/express.ts @@ -2,7 +2,9 @@ import { randomUUID } from 'node:crypto' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' import express from 'express' import type { TransloaditMcpHttpOptions } from './http.ts' +import { isBasicAuthorized } from './http-helpers.ts' import { createMcpRequestHandler } from './http-request-handler.ts' +import { getMetrics, getMetricsContentType } from './metrics.ts' import { createTransloaditMcpServer } from './server.ts' export type TransloaditMcpExpressOptions = TransloaditMcpHttpOptions & { @@ -24,6 +26,9 @@ export const createTransloaditMcpExpressRouter = async ( const router = express.Router() const routePath = options.path ?? '/mcp' + const metricsPath = + options.metricsPath === false ? undefined : (options.metricsPath ?? '/metrics') + const metricsAuth = options.metricsAuth const handler = createMcpRequestHandler(transport, { allowedOrigins: options.allowedOrigins, mcpToken: options.mcpToken, @@ -36,5 +41,24 @@ export const createTransloaditMcpExpressRouter = async ( void handler(req, res) }) + if (metricsPath) { + router.get(metricsPath, async (req, res) => { + if (metricsAuth && !isBasicAuthorized(req, metricsAuth)) { + res.status(401).setHeader('WWW-Authenticate', 'Basic realm="metrics"').send('Unauthorized') + return + } + res.setHeader('Content-Type', getMetricsContentType()) + res.status(200).send(await getMetrics()) + }) + router.head(metricsPath, (req, res) => { + if (metricsAuth && !isBasicAuthorized(req, metricsAuth)) { + res.status(401).setHeader('WWW-Authenticate', 'Basic realm="metrics"').end('Unauthorized') + return + } + res.setHeader('Content-Type', getMetricsContentType()) + res.status(200).end() + }) + } + return router } diff --git a/packages/mcp-server/src/http-helpers.ts b/packages/mcp-server/src/http-helpers.ts index 681d9f87..89b83810 100644 --- a/packages/mcp-server/src/http-helpers.ts +++ b/packages/mcp-server/src/http-helpers.ts @@ -19,13 +19,49 @@ export const extractBearerToken = (header: string | undefined): string | undefin return token ? token : undefined } +export const extractBasicAuth = ( + header: string | undefined, +): { username: string; password: string } | undefined => { + if (!header) return undefined + const match = header.trim().match(/^Basic\s+(.+)$/i) + const token = match?.[1]?.trim() + if (!token) return undefined + try { + const decoded = Buffer.from(token, 'base64').toString('utf8') + const separatorIndex = decoded.indexOf(':') + if (separatorIndex === -1) return undefined + const username = decoded.slice(0, separatorIndex) + const password = decoded.slice(separatorIndex + 1) + if (!username || !password) return undefined + return { username, password } + } catch { + return undefined + } +} + +const timingSafeEqualString = (a: string, b: string): boolean => { + const bufferA = Buffer.from(a) + const bufferB = Buffer.from(b) + if (bufferA.length !== bufferB.length) return false + return timingSafeEqual(bufferA, bufferB) +} + export const isAuthorized = (req: IncomingMessage, token: string): boolean => { const provided = extractBearerToken(req.headers.authorization) if (!provided) return false - const a = Buffer.from(provided) - const b = Buffer.from(token) - if (a.length !== b.length) return false - return timingSafeEqual(a, b) + return timingSafeEqualString(provided, token) +} + +export const isBasicAuthorized = ( + req: IncomingMessage, + expected: { username: string; password: string }, +): boolean => { + const provided = extractBasicAuth(req.headers.authorization) + if (!provided) return false + return ( + timingSafeEqualString(provided.username, expected.username) && + timingSafeEqualString(provided.password, expected.password) + ) } export const applyCorsHeaders = ( diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts index 54479104..30d3c9c8 100644 --- a/packages/mcp-server/src/http.ts +++ b/packages/mcp-server/src/http.ts @@ -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') { + 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 () => { diff --git a/packages/mcp-server/src/metrics.ts b/packages/mcp-server/src/metrics.ts new file mode 100644 index 00000000..33add32d --- /dev/null +++ b/packages/mcp-server/src/metrics.ts @@ -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 => { + ensureDefaults() + return registry.metrics() +} + +export const getMetricsContentType = (): string => { + ensureDefaults() + return registry.contentType +} diff --git a/packages/mcp-server/test/e2e/metrics.test.ts b/packages/mcp-server/test/e2e/metrics.test.ts new file mode 100644 index 00000000..3bc8cc6b --- /dev/null +++ b/packages/mcp-server/test/e2e/metrics.test.ts @@ -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() + } + }) +}) diff --git a/yarn.lock b/yarn.lock index b2623f29..44ac0ca3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1572,6 +1572,13 @@ __metadata: languageName: node linkType: hard +"@opentelemetry/api@npm:^1.4.0": + version: 1.9.0 + resolution: "@opentelemetry/api@npm:1.9.0" + checksum: 10c0/9aae2fe6e8a3a3eeb6c1fdef78e1939cf05a0f37f8a4fae4d6bf2e09eb1e06f966ece85805626e01ba5fab48072b94f19b835449e58b6d26720ee19a58298add + languageName: node + linkType: hard + "@oxc-resolver/binding-android-arm-eabi@npm:11.16.2": version: 11.16.2 resolution: "@oxc-resolver/binding-android-arm-eabi@npm:11.16.2" @@ -2523,6 +2530,7 @@ __metadata: "@types/express": "npm:^4.17.23" "@types/node": "npm:^24.10.3" express: "npm:^4.21.2" + prom-client: "npm:^15.1.3" zod: "npm:^4.0.0" bin: transloadit-mcp: ./dist/cli.js @@ -3189,6 +3197,13 @@ __metadata: languageName: node linkType: hard +"bintrees@npm:1.0.2": + version: 1.0.2 + resolution: "bintrees@npm:1.0.2" + checksum: 10c0/132944b20c93c1a8f97bf8aa25980a76c6eb4291b7f2df2dbcd01cb5b417c287d3ee0847c7260c9f05f3d5a4233aaa03dec95114e97f308abe9cc3f72bed4a44 + languageName: node + linkType: hard + "body-parser@npm:^2.2.1": version: 2.2.2 resolution: "body-parser@npm:2.2.2" @@ -6521,6 +6536,16 @@ __metadata: languageName: node linkType: hard +"prom-client@npm:^15.1.3": + version: 15.1.3 + resolution: "prom-client@npm:15.1.3" + dependencies: + "@opentelemetry/api": "npm:^1.4.0" + tdigest: "npm:^0.1.1" + checksum: 10c0/816525572e5799a2d1d45af78512fb47d073c842dc899c446e94d17cfc343d04282a1627c488c7ca1bcd47f766446d3e49365ab7249f6d9c22c7664a5bce7021 + languageName: node + linkType: hard + "promise-retry@npm:^2.0.1": version: 2.0.1 resolution: "promise-retry@npm:2.0.1" @@ -7488,6 +7513,15 @@ __metadata: languageName: node linkType: hard +"tdigest@npm:^0.1.1": + version: 0.1.2 + resolution: "tdigest@npm:0.1.2" + dependencies: + bintrees: "npm:1.0.2" + checksum: 10c0/10187b8144b112fcdfd3a5e4e9068efa42c990b1e30cd0d4f35ee8f58f16d1b41bc587e668fa7a6f6ca31308961cbd06cd5d4a4ae1dc388335902ae04f7d57df + languageName: node + linkType: hard + "temp@npm:^0.9.4": version: 0.9.4 resolution: "temp@npm:0.9.4"