Skip to content
Open
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ All notable changes to mcp-audit are documented in this file.

### Added

- HTTP proxy request-body and header limits, complete server timeouts, optional
browser Origin validation, Host validation for DNS-rebinding protection, and
`mcp_audit_http_request_rejections_total` metrics.
- `proxy.bind_address` with an explicit `127.0.0.1` value in the distributed
config. Omitted legacy configs retain all-interface binding with a warning.
- Installation cookbook with platform-specific notes in `INSTALL.md`.
- VS Code stdio configuration example under `examples/vscode/`.
- Claude Desktop stdio configuration example under `examples/claude-desktop/`.
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,17 @@ Prometheus metrics are available at `http://localhost:9091/metrics` by default.
| --- | --- | --- |
| `proxy.transport` | `stdio` | Proxy transport: `stdio` or `http`. |
| `proxy.upstream` | required | Stdio command or HTTP upstream URL. |
| `proxy.bind_address` | empty (legacy) | HTTP listen address. The distributed config uses `127.0.0.1`; an omitted value retains all-interface binding with a warning until v2.0.0. |
| `proxy.port` | `4422` | HTTP listen port. |
| `proxy.upstream_timeout_ms` | `30000` | HTTP upstream request timeout in milliseconds. |
| `proxy.http.max_request_body_bytes` | `10485760` | Maximum incoming HTTP request body size. Oversized requests return `413`. |
| `proxy.http.max_header_bytes` | `1048576` | Maximum incoming HTTP header size. |
| `proxy.http.read_header_timeout` | `10s` | Time allowed to read request headers. |
| `proxy.http.read_timeout` | `30s` | Time allowed to read the full request. |
| `proxy.http.write_timeout` | `30s` | Time allowed to write the response. |
| `proxy.http.idle_timeout` | `120s` | Keep-alive idle timeout. |
| `proxy.http.allowed_origins` | empty | Optional exact browser Origin allowlist. Requests without `Origin` remain valid non-browser clients. |
| `proxy.http.allowed_hosts` | empty | Optional Host allowlist for DNS-rebinding protection. Entries are hostnames or IP addresses with optional ports. |
| `proxy.forward_headers` | empty | Request headers allowed to bypass the default upstream strip list. Use `["Authorization"]` only when the upstream MCP HTTP server requires bearer-token auth. |
| `proxy.tls.ca_file` | empty | Optional CA bundle used to verify an HTTPS upstream MCP server. |
| `proxy.tls.server_name` | empty | Optional TLS server name override for the upstream MCP server. |
Expand Down
3 changes: 3 additions & 0 deletions STABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ The following surfaces are covered by the stability policy starting at `v1.0.0`:
- Removing or renaming a key requires a MAJOR bump and a deprecation period (see [Deprecation](#deprecation)).
- The dashboard authentication keys (`dashboard.auth.token`) and dashboard bind address key (`dashboard.bind_address`) are part of the stable configuration surface.
- `proxy.forward_headers` is part of the stable configuration surface. Forwarded headers are passed verbatim to the trusted upstream HTTP MCP server, but HTTP headers are not recorded as dedicated fields in audit entries.
- `proxy.bind_address` and the `proxy.http.*` request-limit, timeout, Origin, and Host validation keys are part of the stable configuration surface.
- The JSONL rotation keys (`audit.rotation.max_size_bytes`, `audit.rotation.max_files`, `audit.rotation.interval`, `audit.rotation.max_age_days`) are part of the stable configuration surface.

### CLI flags
Expand All @@ -41,6 +42,8 @@ The signature is computed over `id + timestamp + method + tool_name + params`. C

Metric names and label sets are stable. The `mcp_audit_*` prefix is reserved. New metrics are additive. A metric is never removed or renamed in a MINOR release.

`mcp_audit_http_request_rejections_total{reason}` counts requests rejected before upstream forwarding. Stable reasons are `body_too_large`, `origin`, and `host`.

### OTLP export attributes

MCP and GenAI semantic convention attributes follow the upstream OpenTelemetry semantic conventions as they evolve. Because the MCP semconv is itself marked **Development** upstream, renames at that layer are tracked and reflected in MINOR releases, with a note in the changelog when this happens. Project-scoped attributes under the `mcp_audit.*` prefix are stable.
Expand Down
79 changes: 79 additions & 0 deletions cmd/mcp-audit/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"testing"
"time"

"github.com/P4ST4S/mcp-audit/internal/audit"
"github.com/P4ST4S/mcp-audit/internal/dashboard"
Expand Down Expand Up @@ -38,6 +39,47 @@ func TestLoadConfigUsesDefaultUpstreamTimeout(t *testing.T) {
}
}

func TestLoadConfigReadsHTTPHardening(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yaml")
raw := []byte(`proxy:
transport: http
upstream: http://upstream.local
bind_address: 127.0.0.1
http:
max_request_body_bytes: 4096
max_header_bytes: 8192
read_header_timeout: 2s
read_timeout: 3s
write_timeout: 4s
idle_timeout: 5s
allowed_origins:
- https://internal.example.com
allowed_hosts:
- localhost
`)
if err := os.WriteFile(configPath, raw, 0644); err != nil {
t.Fatalf("write config: %v", err)
}

config, err := loadConfig(cliFlags{config: configPath, set: map[string]bool{}})
if err != nil {
t.Fatalf("load config: %v", err)
}
if config.Proxy.BindAddress != "127.0.0.1" {
t.Fatalf("bind address = %q", config.Proxy.BindAddress)
}
if config.Proxy.HTTP.MaxRequestBodyBytes != 4096 || config.Proxy.HTTP.MaxHeaderBytes != 8192 {
t.Fatalf("HTTP limits = %d/%d", config.Proxy.HTTP.MaxRequestBodyBytes, config.Proxy.HTTP.MaxHeaderBytes)
}
if config.Proxy.HTTP.ReadHeaderTimeout != 2*time.Second || config.Proxy.HTTP.ReadTimeout != 3*time.Second ||
config.Proxy.HTTP.WriteTimeout != 4*time.Second || config.Proxy.HTTP.IdleTimeout != 5*time.Second {
t.Fatalf("HTTP timeouts = %#v", config.Proxy.HTTP)
}
if len(config.Proxy.HTTP.AllowedOrigins) != 1 || len(config.Proxy.HTTP.AllowedHosts) != 1 {
t.Fatalf("HTTP access lists = %#v", config.Proxy.HTTP)
}
}

// TestLoadConfigReadsUpstreamTimeout verifies config.yaml can set the HTTP
// upstream timeout.
func TestLoadConfigReadsUpstreamTimeout(t *testing.T) {
Expand Down Expand Up @@ -234,6 +276,29 @@ func TestValidateConfigRejectsInvalidUpstreamTimeout(t *testing.T) {
}
}

func TestValidateConfigRejectsInvalidHTTPHardening(t *testing.T) {
cases := []struct {
name string
configure func(*appConfig)
}{
{name: "body limit", configure: func(config *appConfig) { config.Proxy.HTTP.MaxRequestBodyBytes = 0 }},
{name: "header limit", configure: func(config *appConfig) { config.Proxy.HTTP.MaxHeaderBytes = 0 }},
{name: "read header timeout", configure: func(config *appConfig) { config.Proxy.HTTP.ReadHeaderTimeout = 0 }},
{name: "bind whitespace", configure: func(config *appConfig) { config.Proxy.BindAddress = "127.0.0.1 " }},
{name: "origin", configure: func(config *appConfig) { config.Proxy.HTTP.AllowedOrigins = []string{"file:///tmp"} }},
{name: "host", configure: func(config *appConfig) { config.Proxy.HTTP.AllowedHosts = []string{"https://example.com"} }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
config := minimalValidHTTPConfig()
tc.configure(&config)
if err := validateConfig(config); err == nil {
t.Fatal("expected invalid HTTP hardening config")
}
})
}
}

func TestValidateConfigRejectsInvalidProxyRetry(t *testing.T) {
config := appConfig{}
config.Proxy.Transport = "http"
Expand Down Expand Up @@ -466,6 +531,20 @@ func minimalValidConfig() appConfig {
return config
}

func minimalValidHTTPConfig() appConfig {
config := minimalValidConfig()
config.Proxy.Transport = "http"
config.Proxy.Upstream = "http://upstream.local"
config.Proxy.UpstreamTimeoutMS = proxy.DefaultHTTPUpstreamTimeoutMS
config.Proxy.HTTP.MaxRequestBodyBytes = proxy.DefaultHTTPMaxRequestBodyBytes
config.Proxy.HTTP.MaxHeaderBytes = proxy.DefaultHTTPMaxHeaderBytes
config.Proxy.HTTP.ReadHeaderTimeout = proxy.DefaultHTTPReadHeaderTimeout
config.Proxy.HTTP.ReadTimeout = proxy.DefaultHTTPReadTimeout
config.Proxy.HTTP.WriteTimeout = proxy.DefaultHTTPWriteTimeout
config.Proxy.HTTP.IdleTimeout = proxy.DefaultHTTPIdleTimeout
return config
}

type cmdMemoryStore struct{}

func (s *cmdMemoryStore) Append(audit.Entry) error { return nil }
Expand Down
62 changes: 56 additions & 6 deletions cmd/mcp-audit/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,21 @@ type appConfig struct {
Proxy struct {
Transport string `mapstructure:"transport"`
Upstream string `mapstructure:"upstream"`
BindAddress string `mapstructure:"bind_address"`
Port int `mapstructure:"port"`
UpstreamTimeoutMS int `mapstructure:"upstream_timeout_ms"`
ForwardHeaders []string `mapstructure:"forward_headers"`
TLS struct {
HTTP struct {
MaxRequestBodyBytes int64 `mapstructure:"max_request_body_bytes"`
MaxHeaderBytes int `mapstructure:"max_header_bytes"`
ReadHeaderTimeout time.Duration `mapstructure:"read_header_timeout"`
ReadTimeout time.Duration `mapstructure:"read_timeout"`
WriteTimeout time.Duration `mapstructure:"write_timeout"`
IdleTimeout time.Duration `mapstructure:"idle_timeout"`
AllowedOrigins []string `mapstructure:"allowed_origins"`
AllowedHosts []string `mapstructure:"allowed_hosts"`
} `mapstructure:"http"`
TLS struct {
CAFile string `mapstructure:"ca_file"`
ServerName string `mapstructure:"server_name"`
InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"`
Expand Down Expand Up @@ -153,6 +164,9 @@ func main() {
os.Exit(1)
}
logger = newLogger(configuredLogLevel(flags))
if config.Proxy.Transport == "http" && strings.TrimSpace(config.Proxy.BindAddress) == "" {
logger.Warn("proxy.bind_address is not configured; legacy all-interface binding is active")
}

metricsRecorder, metricsServer, err := newMetrics(config, logger)
if err != nil {
Expand Down Expand Up @@ -245,10 +259,19 @@ func main() {
err = stdio.Run(ctx)
case "http":
httpProxy, err := proxy.NewHTTPProxy(proxy.HTTPConfig{
Upstream: config.Proxy.Upstream,
Port: config.Proxy.Port,
UpstreamTimeoutMS: config.Proxy.UpstreamTimeoutMS,
ForwardHeaders: config.Proxy.ForwardHeaders,
Upstream: config.Proxy.Upstream,
BindAddress: config.Proxy.BindAddress,
Port: config.Proxy.Port,
UpstreamTimeoutMS: config.Proxy.UpstreamTimeoutMS,
ForwardHeaders: config.Proxy.ForwardHeaders,
MaxRequestBodyBytes: config.Proxy.HTTP.MaxRequestBodyBytes,
MaxHeaderBytes: config.Proxy.HTTP.MaxHeaderBytes,
ReadHeaderTimeout: config.Proxy.HTTP.ReadHeaderTimeout,
ReadTimeout: config.Proxy.HTTP.ReadTimeout,
WriteTimeout: config.Proxy.HTTP.WriteTimeout,
IdleTimeout: config.Proxy.HTTP.IdleTimeout,
AllowedOrigins: config.Proxy.HTTP.AllowedOrigins,
AllowedHosts: config.Proxy.HTTP.AllowedHosts,
TLS: httpclient.TLSConfig{
CAFile: config.Proxy.TLS.CAFile,
ServerName: config.Proxy.TLS.ServerName,
Expand All @@ -273,7 +296,7 @@ func main() {
logger.Error("failed to create http proxy", "error", err)
os.Exit(1)
}
logger.Info("http proxy listening", "port", config.Proxy.Port, "upstream", config.Proxy.Upstream)
logger.Info("http proxy listening", "bind_address", config.Proxy.BindAddress, "port", config.Proxy.Port, "upstream", config.Proxy.Upstream)
err = httpProxy.ListenAndServe(ctx)
default:
err = fmt.Errorf("main: unknown transport %q", config.Proxy.Transport)
Expand Down Expand Up @@ -378,9 +401,18 @@ func dashboardConfigFromApp(config appConfig, store audit.Store, logger *slog.Lo

func setDefaults(v *viper.Viper) {
v.SetDefault("proxy.transport", "stdio")
v.SetDefault("proxy.bind_address", "")
v.SetDefault("proxy.port", 4422)
v.SetDefault("proxy.upstream_timeout_ms", proxy.DefaultHTTPUpstreamTimeoutMS)
v.SetDefault("proxy.forward_headers", []string{})
v.SetDefault("proxy.http.max_request_body_bytes", proxy.DefaultHTTPMaxRequestBodyBytes)
v.SetDefault("proxy.http.max_header_bytes", proxy.DefaultHTTPMaxHeaderBytes)
v.SetDefault("proxy.http.read_header_timeout", proxy.DefaultHTTPReadHeaderTimeout)
v.SetDefault("proxy.http.read_timeout", proxy.DefaultHTTPReadTimeout)
v.SetDefault("proxy.http.write_timeout", proxy.DefaultHTTPWriteTimeout)
v.SetDefault("proxy.http.idle_timeout", proxy.DefaultHTTPIdleTimeout)
v.SetDefault("proxy.http.allowed_origins", []string{})
v.SetDefault("proxy.http.allowed_hosts", []string{})
v.SetDefault("proxy.tls.ca_file", "")
v.SetDefault("proxy.tls.server_name", "")
v.SetDefault("proxy.tls.insecure_skip_verify", false)
Expand Down Expand Up @@ -472,6 +504,24 @@ func validateConfig(config appConfig) error {
if config.Proxy.Transport == "http" && config.Proxy.UpstreamTimeoutMS <= 0 {
return fmt.Errorf("main: proxy.upstream_timeout_ms must be > 0")
}
if config.Proxy.Transport == "http" {
if strings.TrimSpace(config.Proxy.BindAddress) != config.Proxy.BindAddress || strings.ContainsFunc(config.Proxy.BindAddress, unicode.IsSpace) {
return fmt.Errorf("main: proxy.bind_address must not contain whitespace")
}
if config.Proxy.HTTP.MaxRequestBodyBytes <= 0 {
return fmt.Errorf("main: proxy.http.max_request_body_bytes must be > 0")
}
if config.Proxy.HTTP.MaxHeaderBytes <= 0 {
return fmt.Errorf("main: proxy.http.max_header_bytes must be > 0")
}
if config.Proxy.HTTP.ReadHeaderTimeout <= 0 || config.Proxy.HTTP.ReadTimeout <= 0 ||
config.Proxy.HTTP.WriteTimeout <= 0 || config.Proxy.HTTP.IdleTimeout <= 0 {
return fmt.Errorf("main: proxy.http timeouts must be > 0")
}
if err := proxy.ValidateHTTPAccessLists(config.Proxy.HTTP.AllowedOrigins, config.Proxy.HTTP.AllowedHosts); err != nil {
return fmt.Errorf("main: %w", err)
}
}
if config.Proxy.Retry.MaxRetries < 0 {
return fmt.Errorf("main: proxy.retry.max_retries must be >= 0")
}
Expand Down
13 changes: 13 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
proxy:
transport: stdio
upstream: "npx @modelcontextprotocol/server-filesystem /tmp"
bind_address: "127.0.0.1"
port: 4422
upstream_timeout_ms: 30000
http:
max_request_body_bytes: 10485760
max_header_bytes: 1048576
read_header_timeout: 10s
read_timeout: 30s
write_timeout: 30s
idle_timeout: 120s
allowed_origins: []
allowed_hosts:
- localhost
- 127.0.0.1
- "::1"
tls:
ca_file: ""
server_name: ""
Expand Down
15 changes: 15 additions & 0 deletions internal/metrics/recorder.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type Recorder interface {
RecordPolicyDecision(action string)
RecordRateLimitRejection(clientID, toolName string)
RecordHTTPUpstreamRetry(reason string)
RecordHTTPRequestRejection(reason string)
RecordStorageWrite(backend, mode, status string, duration time.Duration, entries int)
RecordOTelExport(status string, duration time.Duration, spans int)
RecordOTelDrop(reason string, spans int)
Expand All @@ -53,6 +54,7 @@ func (noopRecorder) RecordAuditEntry(audit.Entry)
func (noopRecorder) RecordPolicyDecision(string) {}
func (noopRecorder) RecordRateLimitRejection(string, string) {}
func (noopRecorder) RecordHTTPUpstreamRetry(string) {}
func (noopRecorder) RecordHTTPRequestRejection(string) {}
func (noopRecorder) RecordStorageWrite(string, string, string, time.Duration, int) {}
func (noopRecorder) RecordOTelExport(string, time.Duration, int) {}
func (noopRecorder) RecordOTelDrop(string, int) {}
Expand All @@ -74,6 +76,7 @@ type PrometheusRecorder struct {
toolCalls *prometheus.CounterVec
rateLimitRejects *prometheus.CounterVec
upstreamRetries *prometheus.CounterVec
httpRejects *prometheus.CounterVec
storageWrites *prometheus.CounterVec
storageWriteTime *prometheus.HistogramVec
otelExports *prometheus.CounterVec
Expand Down Expand Up @@ -126,6 +129,10 @@ func NewPrometheusRecorder(config Config) (*PrometheusRecorder, error) {
Name: "mcp_audit_http_upstream_retries_total",
Help: "Total HTTP upstream retry attempts.",
}, []string{"reason"}),
httpRejects: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "mcp_audit_http_request_rejections_total",
Help: "Total HTTP requests rejected before upstream forwarding.",
}, []string{"reason"}),
storageWrites: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "mcp_audit_storage_writes_total",
Help: "Total audit storage writes.",
Expand Down Expand Up @@ -176,6 +183,7 @@ func NewPrometheusRecorder(config Config) (*PrometheusRecorder, error) {
recorder.auditEntries,
recorder.policyDecisions,
recorder.upstreamRetries,
recorder.httpRejects,
recorder.storageWrites,
recorder.storageWriteTime,
recorder.otelExports,
Expand Down Expand Up @@ -298,6 +306,13 @@ func (r *PrometheusRecorder) RecordHTTPUpstreamRetry(reason string) {
r.upstreamRetries.WithLabelValues(reason).Inc()
}

func (r *PrometheusRecorder) RecordHTTPRequestRejection(reason string) {
if reason == "" {
reason = "unknown"
}
r.httpRejects.WithLabelValues(reason).Inc()
}

func (r *PrometheusRecorder) RecordStorageWrite(backend, mode, status string, duration time.Duration, entries int) {
if entries <= 0 {
return
Expand Down
4 changes: 4 additions & 0 deletions internal/metrics/recorder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func TestPrometheusRecorderExposesApplicationMetrics(t *testing.T) {
recorder.RecordRateLimitRejection("client", "read_file")
recorder.RecordPolicyDecision("deny")
recorder.RecordHTTPUpstreamRetry("503")
recorder.RecordHTTPRequestRejection("body_too_large")
recorder.RecordStorageWrite("jsonl", "async", "ok", 10*time.Millisecond, 3)
recorder.RecordOTelExport("ok", 20*time.Millisecond, 2)
recorder.RecordOTelDrop("queue_full", 1)
Expand All @@ -48,6 +49,7 @@ func TestPrometheusRecorderExposesApplicationMetrics(t *testing.T) {
`mcp_audit_tool_calls_total{status="ok",tool_name="read_file",transport="stdio"} 1`,
`mcp_audit_rate_limit_rejections_total{client_id="client",tool_name="read_file"} 1`,
`mcp_audit_http_upstream_retries_total{reason="503"} 1`,
`mcp_audit_http_request_rejections_total{reason="body_too_large"} 1`,
`mcp_audit_storage_writes_total{backend="jsonl",mode="async",status="ok"} 3`,
`mcp_audit_otel_export_requests_total{status="ok"} 1`,
`mcp_audit_otel_spans_total{status="ok"} 2`,
Expand Down Expand Up @@ -146,6 +148,7 @@ func TestPrometheusRecorderFallsBackOnEmptyLabels(t *testing.T) {
recorder.RecordPolicyDecision("")
recorder.RecordRateLimitRejection("", "")
recorder.RecordHTTPUpstreamRetry("")
recorder.RecordHTTPRequestRejection("")
recorder.RecordOTelExport("", 5*time.Millisecond, 1)
recorder.RecordOTelDrop("", 1)

Expand All @@ -158,6 +161,7 @@ func TestPrometheusRecorderFallsBackOnEmptyLabels(t *testing.T) {
`mcp_audit_policy_decisions_total{action="unknown"}`,
`mcp_audit_rate_limit_rejections_total{client_id="unknown",tool_name="unknown"}`,
`mcp_audit_http_upstream_retries_total{reason="unknown"}`,
`mcp_audit_http_request_rejections_total{reason="unknown"}`,
`mcp_audit_otel_export_requests_total{status="unknown"}`,
`mcp_audit_otel_spans_dropped_total{reason="unknown"}`,
} {
Expand Down
Loading
Loading