Skip to content

Commit b7795dd

Browse files
authored
feat(mcp-server): add prometheus metrics endpoint (#305)
* feat(mcp-server): add prometheus metrics endpoint * chore(changeset): mcp-server metrics * feat(mcp-server): protect metrics with basic auth
1 parent 4576b42 commit b7795dd

10 files changed

Lines changed: 248 additions & 6 deletions

File tree

.changeset/mcp-server-metrics.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@transloadit/mcp-server": minor
3+
---
4+
5+
Add Prometheus-compatible metrics endpoint support.

packages/mcp-server/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,21 @@ Environment:
4343
- `TRANSLOADIT_SECRET`
4444
- `TRANSLOADIT_MCP_TOKEN`
4545
- `TRANSLOADIT_ENDPOINT` (optional, default `https://api2.transloadit.com`)
46+
- `TRANSLOADIT_MCP_METRICS_PATH` (optional, default `/metrics`)
4647

4748
CLI:
4849

4950
- `transloadit-mcp http --host 127.0.0.1 --port 5723 --endpoint https://api2.transloadit.com`
5051
- `transloadit-mcp http --config path/to/config.json`
5152

53+
## Metrics
54+
55+
- Prometheus-compatible metrics are exposed at `GET /metrics` by default.
56+
- Customize the path via `TRANSLOADIT_MCP_METRICS_PATH` or config `metricsPath`.
57+
- Disable by setting `metricsPath: false` in the config or when creating the server/router.
58+
- Optional basic auth via `TRANSLOADIT_MCP_METRICS_USER` +
59+
`TRANSLOADIT_MCP_METRICS_PASSWORD` or config `metricsAuth`.
60+
5261
## Input files
5362

5463
```ts

packages/mcp-server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"@transloadit/node": "^4.3.1",
4343
"@transloadit/sev-logger": "^0.1.9",
4444
"express": "^4.21.2",
45+
"prom-client": "^15.1.3",
4546
"zod": "^4.0.0"
4647
},
4748
"devDependencies": {

packages/mcp-server/src/cli.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ Environment:
1818
TRANSLOADIT_SECRET
1919
TRANSLOADIT_MCP_TOKEN
2020
TRANSLOADIT_ENDPOINT
21+
TRANSLOADIT_MCP_METRICS_PATH
22+
TRANSLOADIT_MCP_METRICS_USER
23+
TRANSLOADIT_MCP_METRICS_PASSWORD
2124
`)
2225
}
2326

@@ -106,6 +109,17 @@ const main = async (): Promise<void> => {
106109
const host = (config.host ?? fileConfig.host ?? '127.0.0.1') as string
107110
const port = Number(config.port ?? fileConfig.port ?? 5723)
108111
const path = (fileConfig.path as string | undefined) ?? '/mcp'
112+
const metricsPath =
113+
(fileConfig.metricsPath as string | undefined) ?? process.env.TRANSLOADIT_MCP_METRICS_PATH
114+
const metricsAuthConfig = fileConfig.metricsAuth as
115+
| { username?: string; password?: string }
116+
| undefined
117+
const metricsUser = (fileConfig.metricsUser ??
118+
metricsAuthConfig?.username ??
119+
process.env.TRANSLOADIT_MCP_METRICS_USER) as string | undefined
120+
const metricsPassword = (fileConfig.metricsPassword ??
121+
metricsAuthConfig?.password ??
122+
process.env.TRANSLOADIT_MCP_METRICS_PASSWORD) as string | undefined
109123
const endpoint = (config.endpoint ?? fileConfig.endpoint ?? process.env.TRANSLOADIT_ENDPOINT) as
110124
| string
111125
| undefined
@@ -126,6 +140,11 @@ const main = async (): Promise<void> => {
126140
allowedHosts: fileConfig.allowedHosts as string[] | undefined,
127141
enableDnsRebindingProtection: fileConfig.enableDnsRebindingProtection as boolean | undefined,
128142
path,
143+
metricsPath,
144+
metricsAuth:
145+
metricsUser && metricsPassword
146+
? { username: metricsUser, password: metricsPassword }
147+
: undefined,
129148
logger,
130149
})
131150

packages/mcp-server/src/express.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { randomUUID } from 'node:crypto'
22
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
33
import express from 'express'
44
import type { TransloaditMcpHttpOptions } from './http.ts'
5+
import { isBasicAuthorized } from './http-helpers.ts'
56
import { createMcpRequestHandler } from './http-request-handler.ts'
7+
import { getMetrics, getMetricsContentType } from './metrics.ts'
68
import { createTransloaditMcpServer } from './server.ts'
79

810
export type TransloaditMcpExpressOptions = TransloaditMcpHttpOptions & {
@@ -24,6 +26,9 @@ export const createTransloaditMcpExpressRouter = async (
2426

2527
const router = express.Router()
2628
const routePath = options.path ?? '/mcp'
29+
const metricsPath =
30+
options.metricsPath === false ? undefined : (options.metricsPath ?? '/metrics')
31+
const metricsAuth = options.metricsAuth
2732
const handler = createMcpRequestHandler(transport, {
2833
allowedOrigins: options.allowedOrigins,
2934
mcpToken: options.mcpToken,
@@ -36,5 +41,24 @@ export const createTransloaditMcpExpressRouter = async (
3641
void handler(req, res)
3742
})
3843

44+
if (metricsPath) {
45+
router.get(metricsPath, async (req, res) => {
46+
if (metricsAuth && !isBasicAuthorized(req, metricsAuth)) {
47+
res.status(401).setHeader('WWW-Authenticate', 'Basic realm="metrics"').send('Unauthorized')
48+
return
49+
}
50+
res.setHeader('Content-Type', getMetricsContentType())
51+
res.status(200).send(await getMetrics())
52+
})
53+
router.head(metricsPath, (req, res) => {
54+
if (metricsAuth && !isBasicAuthorized(req, metricsAuth)) {
55+
res.status(401).setHeader('WWW-Authenticate', 'Basic realm="metrics"').end('Unauthorized')
56+
return
57+
}
58+
res.setHeader('Content-Type', getMetricsContentType())
59+
res.status(200).end()
60+
})
61+
}
62+
3963
return router
4064
}

packages/mcp-server/src/http-helpers.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,49 @@ export const extractBearerToken = (header: string | undefined): string | undefin
1919
return token ? token : undefined
2020
}
2121

22+
export const extractBasicAuth = (
23+
header: string | undefined,
24+
): { username: string; password: string } | undefined => {
25+
if (!header) return undefined
26+
const match = header.trim().match(/^Basic\s+(.+)$/i)
27+
const token = match?.[1]?.trim()
28+
if (!token) return undefined
29+
try {
30+
const decoded = Buffer.from(token, 'base64').toString('utf8')
31+
const separatorIndex = decoded.indexOf(':')
32+
if (separatorIndex === -1) return undefined
33+
const username = decoded.slice(0, separatorIndex)
34+
const password = decoded.slice(separatorIndex + 1)
35+
if (!username || !password) return undefined
36+
return { username, password }
37+
} catch {
38+
return undefined
39+
}
40+
}
41+
42+
const timingSafeEqualString = (a: string, b: string): boolean => {
43+
const bufferA = Buffer.from(a)
44+
const bufferB = Buffer.from(b)
45+
if (bufferA.length !== bufferB.length) return false
46+
return timingSafeEqual(bufferA, bufferB)
47+
}
48+
2249
export const isAuthorized = (req: IncomingMessage, token: string): boolean => {
2350
const provided = extractBearerToken(req.headers.authorization)
2451
if (!provided) return false
25-
const a = Buffer.from(provided)
26-
const b = Buffer.from(token)
27-
if (a.length !== b.length) return false
28-
return timingSafeEqual(a, b)
52+
return timingSafeEqualString(provided, token)
53+
}
54+
55+
export const isBasicAuthorized = (
56+
req: IncomingMessage,
57+
expected: { username: string; password: string },
58+
): boolean => {
59+
const provided = extractBasicAuth(req.headers.authorization)
60+
if (!provided) return false
61+
return (
62+
timingSafeEqualString(provided.username, expected.username) &&
63+
timingSafeEqualString(provided.password, expected.password)
64+
)
2965
}
3066

3167
export const applyCorsHeaders = (

packages/mcp-server/src/http.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { randomUUID } from 'node:crypto'
22
import type { IncomingMessage, ServerResponse } from 'node:http'
33
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
44
import type { SevLogger } from '@transloadit/sev-logger'
5+
import { isBasicAuthorized, normalizePath, parsePathname } from './http-helpers.ts'
56
import { createMcpRequestHandler } from './http-request-handler.ts'
7+
import { getMetrics, getMetricsContentType } from './metrics.ts'
68
import type { TransloaditMcpServerOptions } from './server.ts'
79
import { createTransloaditMcpServer } from './server.ts'
810

@@ -12,6 +14,8 @@ export type TransloaditMcpHttpOptions = TransloaditMcpServerOptions & {
1214
enableDnsRebindingProtection?: boolean
1315
mcpToken?: string
1416
path?: string
17+
metricsPath?: string | false
18+
metricsAuth?: { username: string; password: string }
1519
sessionIdGenerator?: (() => string) | undefined
1620
logger?: SevLogger
1721
}
@@ -38,12 +42,47 @@ export const createTransloaditMcpHttpHandler = async (
3842

3943
await server.connect(transport)
4044

41-
const handler = createMcpRequestHandler(transport, {
45+
const expectedPath = options.path ?? defaultPath
46+
const metricsPath =
47+
options.metricsPath === false ? undefined : normalizePath(options.metricsPath ?? '/metrics')
48+
const metricsAuth = options.metricsAuth
49+
50+
const mcpHandler = createMcpRequestHandler(transport, {
4251
allowedOrigins: options.allowedOrigins,
4352
mcpToken: options.mcpToken,
44-
path: { expectedPath: options.path ?? defaultPath },
53+
path: { expectedPath },
4554
logger: options.logger,
4655
redactSecrets: [options.mcpToken, options.authKey, options.authSecret],
56+
})
57+
58+
const handler = (async (req, res) => {
59+
if (metricsPath) {
60+
const pathname = normalizePath(parsePathname(req.url, expectedPath))
61+
if (pathname === metricsPath) {
62+
if (metricsAuth && !isBasicAuthorized(req, metricsAuth)) {
63+
res.statusCode = 401
64+
res.setHeader('WWW-Authenticate', 'Basic realm="metrics"')
65+
res.end('Unauthorized')
66+
return
67+
}
68+
if (req.method !== 'GET' && req.method !== 'HEAD') {
69+
res.statusCode = 405
70+
res.end('Method Not Allowed')
71+
return
72+
}
73+
74+
res.statusCode = 200
75+
res.setHeader('Content-Type', getMetricsContentType())
76+
if (req.method === 'HEAD') {
77+
res.end()
78+
return
79+
}
80+
res.end(await getMetrics())
81+
return
82+
}
83+
}
84+
85+
await mcpHandler(req, res)
4786
}) as TransloaditMcpHttpHandler
4887

4988
handler.close = async () => {

packages/mcp-server/src/metrics.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { collectDefaultMetrics, Registry } from 'prom-client'
2+
3+
const registry = new Registry()
4+
let defaultsStarted = false
5+
6+
const ensureDefaults = (): void => {
7+
if (defaultsStarted) return
8+
collectDefaultMetrics({ register: registry })
9+
defaultsStarted = true
10+
}
11+
12+
export const getMetrics = (): Promise<string> => {
13+
ensureDefaults()
14+
return registry.metrics()
15+
}
16+
17+
export const getMetricsContentType = (): string => {
18+
ensureDefaults()
19+
return registry.contentType
20+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { startHttpServer } from './http-server.ts'
3+
4+
describe('metrics', () => {
5+
it('exposes prometheus metrics', async () => {
6+
const { url, close } = await startHttpServer()
7+
8+
try {
9+
const metricsUrl = new URL(url)
10+
metricsUrl.pathname = '/metrics'
11+
12+
const response = await fetch(metricsUrl)
13+
expect(response.status).toBe(200)
14+
const contentType = response.headers.get('content-type')
15+
expect(contentType).toContain('text/plain')
16+
17+
const body = await response.text()
18+
expect(body).toContain('process_cpu_user_seconds_total')
19+
} finally {
20+
await close()
21+
}
22+
})
23+
24+
it('requires basic auth when configured', async () => {
25+
const { url, close } = await startHttpServer({
26+
metricsAuth: { username: 'metrics-user', password: 'metrics-pass' },
27+
})
28+
29+
try {
30+
const metricsUrl = new URL(url)
31+
metricsUrl.pathname = '/metrics'
32+
33+
const unauthorized = await fetch(metricsUrl)
34+
expect(unauthorized.status).toBe(401)
35+
36+
const wrongAuth = await fetch(metricsUrl, {
37+
headers: {
38+
Authorization: `Basic ${Buffer.from('wrong:creds').toString('base64')}`,
39+
},
40+
})
41+
expect(wrongAuth.status).toBe(401)
42+
43+
const ok = await fetch(metricsUrl, {
44+
headers: {
45+
Authorization: `Basic ${Buffer.from('metrics-user:metrics-pass').toString('base64')}`,
46+
},
47+
})
48+
expect(ok.status).toBe(200)
49+
const body = await ok.text()
50+
expect(body).toContain('process_cpu_user_seconds_total')
51+
} finally {
52+
await close()
53+
}
54+
})
55+
})

yarn.lock

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1572,6 +1572,13 @@ __metadata:
15721572
languageName: node
15731573
linkType: hard
15741574

1575+
"@opentelemetry/api@npm:^1.4.0":
1576+
version: 1.9.0
1577+
resolution: "@opentelemetry/api@npm:1.9.0"
1578+
checksum: 10c0/9aae2fe6e8a3a3eeb6c1fdef78e1939cf05a0f37f8a4fae4d6bf2e09eb1e06f966ece85805626e01ba5fab48072b94f19b835449e58b6d26720ee19a58298add
1579+
languageName: node
1580+
linkType: hard
1581+
15751582
"@oxc-resolver/binding-android-arm-eabi@npm:11.16.2":
15761583
version: 11.16.2
15771584
resolution: "@oxc-resolver/binding-android-arm-eabi@npm:11.16.2"
@@ -2523,6 +2530,7 @@ __metadata:
25232530
"@types/express": "npm:^4.17.23"
25242531
"@types/node": "npm:^24.10.3"
25252532
express: "npm:^4.21.2"
2533+
prom-client: "npm:^15.1.3"
25262534
zod: "npm:^4.0.0"
25272535
bin:
25282536
transloadit-mcp: ./dist/cli.js
@@ -3189,6 +3197,13 @@ __metadata:
31893197
languageName: node
31903198
linkType: hard
31913199

3200+
"bintrees@npm:1.0.2":
3201+
version: 1.0.2
3202+
resolution: "bintrees@npm:1.0.2"
3203+
checksum: 10c0/132944b20c93c1a8f97bf8aa25980a76c6eb4291b7f2df2dbcd01cb5b417c287d3ee0847c7260c9f05f3d5a4233aaa03dec95114e97f308abe9cc3f72bed4a44
3204+
languageName: node
3205+
linkType: hard
3206+
31923207
"body-parser@npm:^2.2.1":
31933208
version: 2.2.2
31943209
resolution: "body-parser@npm:2.2.2"
@@ -6521,6 +6536,16 @@ __metadata:
65216536
languageName: node
65226537
linkType: hard
65236538

6539+
"prom-client@npm:^15.1.3":
6540+
version: 15.1.3
6541+
resolution: "prom-client@npm:15.1.3"
6542+
dependencies:
6543+
"@opentelemetry/api": "npm:^1.4.0"
6544+
tdigest: "npm:^0.1.1"
6545+
checksum: 10c0/816525572e5799a2d1d45af78512fb47d073c842dc899c446e94d17cfc343d04282a1627c488c7ca1bcd47f766446d3e49365ab7249f6d9c22c7664a5bce7021
6546+
languageName: node
6547+
linkType: hard
6548+
65246549
"promise-retry@npm:^2.0.1":
65256550
version: 2.0.1
65266551
resolution: "promise-retry@npm:2.0.1"
@@ -7488,6 +7513,15 @@ __metadata:
74887513
languageName: node
74897514
linkType: hard
74907515

7516+
"tdigest@npm:^0.1.1":
7517+
version: 0.1.2
7518+
resolution: "tdigest@npm:0.1.2"
7519+
dependencies:
7520+
bintrees: "npm:1.0.2"
7521+
checksum: 10c0/10187b8144b112fcdfd3a5e4e9068efa42c990b1e30cd0d4f35ee8f58f16d1b41bc587e668fa7a6f6ca31308961cbd06cd5d4a4ae1dc388335902ae04f7d57df
7522+
languageName: node
7523+
linkType: hard
7524+
74917525
"temp@npm:^0.9.4":
74927526
version: 0.9.4
74937527
resolution: "temp@npm:0.9.4"

0 commit comments

Comments
 (0)