Skip to content

Commit f85ca34

Browse files
committed
feat(mcp-server): protect metrics with basic auth
1 parent 056bb0b commit f85ca34

6 files changed

Lines changed: 110 additions & 7 deletions

File tree

packages/mcp-server/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ CLI:
5555
- Prometheus-compatible metrics are exposed at `GET /metrics` by default.
5656
- Customize the path via `TRANSLOADIT_MCP_METRICS_PATH` or config `metricsPath`.
5757
- 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`.
5860

5961
## Input files
6062

packages/mcp-server/src/cli.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ Environment:
1919
TRANSLOADIT_MCP_TOKEN
2020
TRANSLOADIT_ENDPOINT
2121
TRANSLOADIT_MCP_METRICS_PATH
22+
TRANSLOADIT_MCP_METRICS_USER
23+
TRANSLOADIT_MCP_METRICS_PASSWORD
2224
`)
2325
}
2426

@@ -109,6 +111,15 @@ const main = async (): Promise<void> => {
109111
const path = (fileConfig.path as string | undefined) ?? '/mcp'
110112
const metricsPath =
111113
(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
112123
const endpoint = (config.endpoint ?? fileConfig.endpoint ?? process.env.TRANSLOADIT_ENDPOINT) as
113124
| string
114125
| undefined
@@ -130,6 +141,10 @@ const main = async (): Promise<void> => {
130141
enableDnsRebindingProtection: fileConfig.enableDnsRebindingProtection as boolean | undefined,
131142
path,
132143
metricsPath,
144+
metricsAuth:
145+
metricsUser && metricsPassword
146+
? { username: metricsUser, password: metricsPassword }
147+
: undefined,
133148
logger,
134149
})
135150

packages/mcp-server/src/express.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ 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'
67
import { getMetrics, getMetricsContentType } from './metrics.ts'
78
import { createTransloaditMcpServer } from './server.ts'
@@ -27,6 +28,7 @@ export const createTransloaditMcpExpressRouter = async (
2728
const routePath = options.path ?? '/mcp'
2829
const metricsPath =
2930
options.metricsPath === false ? undefined : (options.metricsPath ?? '/metrics')
31+
const metricsAuth = options.metricsAuth
3032
const handler = createMcpRequestHandler(transport, {
3133
allowedOrigins: options.allowedOrigins,
3234
mcpToken: options.mcpToken,
@@ -40,11 +42,19 @@ export const createTransloaditMcpExpressRouter = async (
4042
})
4143

4244
if (metricsPath) {
43-
router.get(metricsPath, async (_req, res) => {
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+
}
4450
res.setHeader('Content-Type', getMetricsContentType())
4551
res.status(200).send(await getMetrics())
4652
})
47-
router.head(metricsPath, (_req, res) => {
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+
}
4858
res.setHeader('Content-Type', getMetricsContentType())
4959
res.status(200).end()
5060
})

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: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ 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 { normalizePath, parsePathname } from './http-helpers.ts'
5+
import { isBasicAuthorized, normalizePath, parsePathname } from './http-helpers.ts'
66
import { createMcpRequestHandler } from './http-request-handler.ts'
77
import { getMetrics, getMetricsContentType } from './metrics.ts'
88
import type { TransloaditMcpServerOptions } from './server.ts'
@@ -15,6 +15,7 @@ export type TransloaditMcpHttpOptions = TransloaditMcpServerOptions & {
1515
mcpToken?: string
1616
path?: string
1717
metricsPath?: string | false
18+
metricsAuth?: { username: string; password: string }
1819
sessionIdGenerator?: (() => string) | undefined
1920
logger?: SevLogger
2021
}
@@ -44,6 +45,7 @@ export const createTransloaditMcpHttpHandler = async (
4445
const expectedPath = options.path ?? defaultPath
4546
const metricsPath =
4647
options.metricsPath === false ? undefined : normalizePath(options.metricsPath ?? '/metrics')
48+
const metricsAuth = options.metricsAuth
4749

4850
const mcpHandler = createMcpRequestHandler(transport, {
4951
allowedOrigins: options.allowedOrigins,
@@ -57,6 +59,12 @@ export const createTransloaditMcpHttpHandler = async (
5759
if (metricsPath) {
5860
const pathname = normalizePath(parsePathname(req.url, expectedPath))
5961
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+
}
6068
if (req.method !== 'GET' && req.method !== 'HEAD') {
6169
res.statusCode = 405
6270
res.end('Method Not Allowed')

packages/mcp-server/test/e2e/metrics.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,36 @@ describe('metrics', () => {
2020
await close()
2121
}
2222
})
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+
})
2355
})

0 commit comments

Comments
 (0)