Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-server-metrics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@transloadit/mcp-server": minor
---

Add Prometheus-compatible metrics endpoint support.
9 changes: 9 additions & 0 deletions packages/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
19 changes: 19 additions & 0 deletions packages/mcp-server/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ Environment:
TRANSLOADIT_SECRET
TRANSLOADIT_MCP_TOKEN
TRANSLOADIT_ENDPOINT
TRANSLOADIT_MCP_METRICS_PATH
TRANSLOADIT_MCP_METRICS_USER
TRANSLOADIT_MCP_METRICS_PASSWORD
`)
}

Expand Down Expand Up @@ -106,6 +109,17 @@ const main = async (): Promise<void> => {
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
Expand All @@ -126,6 +140,11 @@ const main = async (): Promise<void> => {
allowedHosts: fileConfig.allowedHosts as string[] | undefined,
enableDnsRebindingProtection: fileConfig.enableDnsRebindingProtection as boolean | undefined,
path,
metricsPath,
metricsAuth:
metricsUser && metricsPassword
? { username: metricsUser, password: metricsPassword }
: undefined,
logger,
})

Expand Down
24 changes: 24 additions & 0 deletions packages/mcp-server/src/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand All @@ -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,
Expand All @@ -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())
})
Comment on lines +44 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require auth on the Express metrics endpoint

The Express router adds GET/HEAD handlers for metricsPath without any of the MCP auth/CORS checks, so a configured mcpToken does 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 👍 / 👎.

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
}
44 changes: 40 additions & 4 deletions packages/mcp-server/src/http-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
43 changes: 41 additions & 2 deletions packages/mcp-server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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
}
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require auth on the raw HTTP metrics endpoint

The metrics branch short‑circuits before mcpHandler, so it never applies the bearer token check that createMcpRequestHandler enforces for MCP requests. In deployments where mcpToken is required (e.g., non‑localhost binds), /metrics is still publicly readable and exposes process/runtime metrics to unauthenticated callers. Consider reusing the token check (or a separate metrics token) before returning metrics.

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 () => {
Expand Down
20 changes: 20 additions & 0 deletions packages/mcp-server/src/metrics.ts
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
}
55 changes: 55 additions & 0 deletions packages/mcp-server/test/e2e/metrics.test.ts
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()
}
})
})
34 changes: 34 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
Loading