Files
pad/internal/server/middleware_mcp_client.go
T
xarmian 0b0398b2c0 feat(mcp): count admitted MCP requests by method and client (TASK-2307) (#2003)
* feat(mcp): count authenticated MCP requests by method and client (TASK-2307)

The go-sdk port would run its Streamable HTTP transport stateless, and a
stateless transport answers GET with 405, which ends the standalone SSE
stream. No unit test can say whether a real client opens that stream, so
before any engineering time goes into the port this measures it:
pad_mcp_http_requests_total{mount, method, client}, incremented on entry for
every authenticated request on /mcp and /mcp/chatgpt.

On entry, because pad_http_requests_total records a request when it ends,
and a GET holding an SSE stream open ends when the stream does. After
MCPBearerAuth, so refused requests stay with pad_mcp_preauth_denied_total.
The client label is a closed set read from the User-Agent (claude-code,
claude, codex, openai, cursor, windsurf, vscode, mcp-remote, mcp-inspector,
python, node, go, curl, okhttp, other, none), never the header itself.

* fix(mcp): read the User-Agent through requestUserAgent (TASK-2307)

The BUG-2803 call-site guard refuses a raw User-Agent read; the class is
computed from the sanitised value. The guard's exact count moves from 7
call sites to 8, re-pinned with the reason: like fromNativeShell, the counter
only substring-matches the header into a closed label and binds nothing.

* docs(mcp): name what the request counter admits, not 'every authenticated request' (TASK-2307)

Codex round 1: a caller refused by the per-token rate limit inside
MCPBearerAuth is authenticated and is not counted. That is the intended
boundary (a refused GET opens no stream, and the refusal is already counted
in pad_mcp_authz_denials_total{reason="rate_limited"}), so the comment, the
metric help and docs/deployment.md now say what is counted.

* test(mcp): a rate-limited MCP request is not counted (TASK-2307)

Codex round 2: the documented boundary (MCPBearerAuth's per-token 429 is
not an admitted request) had no test. Counted equals admitted; the mutant
moving the counter ahead of auth counts the 429 and fails.
2026-10-10 00:50:31 -04:00

76 lines
2.5 KiB
Go

package server
import (
"net/http"
"strings"
)
// countMCPRequest counts each request MCPBearerAuth admits to an MCP mount in
// pad_mcp_http_requests_total{mount, method, client} (TASK-2307): the caller is
// authenticated and within its per-token rate limit. It sits right after that
// middleware, so its refusals stay where they are already counted: before a
// caller is known in pad_mcp_preauth_denied_total, a rate-limited caller in
// pad_mcp_authz_denials_total{reason="rate_limited"}. A refused GET opens no
// stream, so it is not what this measures. It counts on ENTRY: a GET that
// opens a server-sent-event stream is counted while the stream is open.
//
// The question it answers: a stateless go-sdk transport refuses GET with 405,
// which ends the standalone SSE stream. This shows whether any real client
// opens that stream, and which.
func (s *Server) countMCPRequest(mount string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.metrics != nil {
s.metrics.MCPHTTPRequestsTotal.WithLabelValues(
mount, metricsMethodLabel(r.Method), mcpClientClass(requestUserAgent(r)),
).Inc()
}
next.ServeHTTP(w, r)
})
}
}
// mcpClientRules maps a User-Agent substring (lowercased) to a client class,
// first match wins, so a more specific needle precedes a broader one
// ("claude-code" before "claude", "codex" before "openai"). The classes are
// the label's whole vocabulary: an unrecognised agent is "other", an absent
// one "none", so a caller cannot mint a series.
var mcpClientRules = []struct{ needle, class string }{
{"claude-code", "claude-code"},
{"claude", "claude"},
{"codex", "codex"},
{"chatgpt", "openai"},
{"openai", "openai"},
{"cursor", "cursor"},
{"windsurf", "windsurf"},
{"codeium", "windsurf"},
{"visual studio code", "vscode"},
{"vscode", "vscode"},
{"copilot", "vscode"},
{"mcp-remote", "mcp-remote"},
{"inspector", "mcp-inspector"},
{"python", "python"},
{"httpx", "python"},
{"aiohttp", "python"},
{"undici", "node"},
{"node", "node"},
{"axios", "node"},
{"go-http-client", "go"},
{"curl", "curl"},
{"okhttp", "okhttp"},
}
// mcpClientClass returns the client class for a User-Agent header value.
func mcpClientClass(userAgent string) string {
ua := strings.ToLower(strings.TrimSpace(userAgent))
if ua == "" {
return "none"
}
for _, r := range mcpClientRules {
if strings.Contains(ua, r.needle) {
return r.class
}
}
return "other"
}