mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
0b0398b2c0
* 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.
76 lines
2.5 KiB
Go
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"
|
|
}
|