diff --git a/CHANGELOG.md b/CHANGELOG.md index d4bdef5..13cb55b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to mcp-audit are documented in this file. ### Added +- MCP 2026-07-28 request inspection and Streamable HTTP forwarding, including + consistency validation for `Mcp-Method`, `Mcp-Name`, and + `Mcp-Protocol-Version` headers. - 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/`. diff --git a/README.md b/README.md index 511290a..9ad2710 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,11 @@ Prometheus metrics are available at `http://localhost:9091/metrics` by default. `mcp-audit` loads `config.yaml` from the current directory by default. CLI flags override config values, and `AUDIT_SECRET` overrides `audit.secret`. +For Streamable HTTP clients, the proxy forwards MCP session, cache, and +multi-round-trip metadata unchanged. When `Mcp-Method`, `Mcp-Name`, or +`Mcp-Protocol-Version` are present, they are validated against the JSON-RPC +body before forwarding. A mismatch returns HTTP 400 with JSON-RPC code -32600. + | Key | Default | Description | | --- | --- | --- | | `proxy.transport` | `stdio` | Proxy transport: `stdio` or `http`. | diff --git a/STABILITY.md b/STABILITY.md index fb77bbe..1e43e65 100644 --- a/STABILITY.md +++ b/STABILITY.md @@ -57,6 +57,9 @@ Successful JSON API responses include `Cache-Control: no-store`. The proxy-emitted JSON-RPC error codes (`-32029` rate-limited, `-32030` policy denied) are stable. +MCP metadata mismatches are rejected before upstream forwarding with HTTP `400` +and the standard JSON-RPC invalid-request code `-32600`. + ## Not part of the stable surface The following are explicitly **not** covered by the stability policy and may change in any release, including MINOR: diff --git a/internal/mcp/metadata.go b/internal/mcp/metadata.go index b5f31cc..129fe58 100644 --- a/internal/mcp/metadata.go +++ b/internal/mcp/metadata.go @@ -1,6 +1,7 @@ package mcp import ( + "bytes" "encoding/json" "fmt" "net/http" @@ -33,17 +34,27 @@ type RequestMetadata struct { // InspectRequest validates MCP headers against a single JSON-RPC request body. func InspectRequest(headers http.Header, body []byte) (RequestMetadata, error) { - messages, err := DecodeMessages(body) + inspectedHeaders, err := inspectHeaders(headers) if err != nil { return RequestMetadata{}, err } - if len(messages) != 1 { - return RequestMetadata{}, fmt.Errorf("mcp: request metadata headers cannot describe a batch") + if len(bytes.TrimSpace(body)) == 0 { + if inspectedHeaders.method != "" || inspectedHeaders.name != "" { + return RequestMetadata{}, fmt.Errorf("mcp: method and name headers require a JSON-RPC request body") + } + revision, err := protocolRevision(inspectedHeaders.revision, Message{}) + if err != nil { + return RequestMetadata{}, err + } + return RequestMetadata{ProtocolRevision: revision}, nil } - inspectedHeaders, err := inspectHeaders(headers) + messages, err := DecodeMessages(body) if err != nil { return RequestMetadata{}, err } + if len(messages) != 1 { + return RequestMetadata{}, fmt.Errorf("mcp: request metadata headers cannot describe a batch") + } message := messages[0] name := nameFromMessage(message) if inspectedHeaders.method != "" && inspectedHeaders.method != message.Method { diff --git a/internal/mcp/metadata_test.go b/internal/mcp/metadata_test.go index e21951d..63f9e89 100644 --- a/internal/mcp/metadata_test.go +++ b/internal/mcp/metadata_test.go @@ -109,3 +109,19 @@ func TestInspectRequestDoesNotMutateBody(t *testing.T) { t.Fatalf("body mutated: %s", body) } } + +func TestInspectRequestAcceptsProtocolOnlyStreamingRequest(t *testing.T) { + metadata, err := InspectRequest(http.Header{HeaderProtocolVersion: {"2026-07-28"}}, nil) + if err != nil { + t.Fatalf("inspect request: %v", err) + } + if metadata.ProtocolRevision != Protocol20260728 || metadata.Method != "" { + t.Fatalf("metadata = %#v", metadata) + } +} + +func TestInspectRequestRejectsMethodHeaderWithoutBody(t *testing.T) { + if _, err := InspectRequest(http.Header{HeaderMethod: {"tools/list"}}, nil); err == nil { + t.Fatal("expected method-without-body error") + } +} diff --git a/internal/proxy/http.go b/internal/proxy/http.go index 188a1a4..188614b 100644 --- a/internal/proxy/http.go +++ b/internal/proxy/http.go @@ -18,6 +18,7 @@ import ( "github.com/P4ST4S/mcp-audit/internal/audit" "github.com/P4ST4S/mcp-audit/internal/httpclient" + "github.com/P4ST4S/mcp-audit/internal/mcp" "github.com/P4ST4S/mcp-audit/internal/middleware" "github.com/P4ST4S/mcp-audit/internal/policy" "github.com/P4ST4S/mcp-audit/internal/retry" @@ -151,6 +152,10 @@ func (p *HTTPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } _ = r.Body.Close() + if err := p.validateMCPRequest(r.Header, body); err != nil { + p.writeMCPMetadataError(w, body, err) + return + } pending, reject := p.observeHTTPRequest(body, startedAt) if reject != nil { @@ -186,6 +191,40 @@ func (p *HTTPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { p.observeHTTPResponse(respBody, pending) } +func (p *HTTPProxy) validateMCPRequest(headers http.Header, body []byte) error { + metadata, err := mcp.InspectRequest(headers, body) + if err != nil { + if !hasMCPMetadataHeaders(headers) { + p.log.Debug("request is not inspectable MCP JSON-RPC", "error", err) + return nil + } + return err + } + if metadata.ProtocolRevision == mcp.Protocol20260728 { + p.log.Debug("inspected MCP 2026 request", "method", metadata.Method, "name", metadata.Name, "request_id", metadata.RequestID) + } + return nil +} + +func (p *HTTPProxy) writeMCPMetadataError(w http.ResponseWriter, body []byte, cause error) { + p.log.Warn("rejected inconsistent MCP request metadata", "error", cause) + id := json.RawMessage("null") + if messages, err := mcp.DecodeMessages(body); err == nil && len(messages) == 1 && len(messages[0].ID) > 0 { + id = messages[0].ID + } + rpcErr := &audit.RPCError{Code: -32600, Message: "invalid MCP request metadata"} + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write(buildErrorResponse(id, rpcErr)) +} + +func hasMCPMetadataHeaders(headers http.Header) bool { + return len(headers.Values(mcp.HeaderMethod)) > 0 || + len(headers.Values(mcp.HeaderName)) > 0 || + len(headers.Values(mcp.HeaderProtocolVersion)) > 0 +} + func (p *HTTPProxy) doUpstreamRequest(r *http.Request, body []byte) (*http.Response, error) { safeToRetry := p.safeToRetry(r.Method, body) retryPolicy := p.retryPolicy() diff --git a/internal/proxy/http_test.go b/internal/proxy/http_test.go index 6c37029..5fb6b07 100644 --- a/internal/proxy/http_test.go +++ b/internal/proxy/http_test.go @@ -7,11 +7,13 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" "time" "github.com/P4ST4S/mcp-audit/internal/audit" "github.com/P4ST4S/mcp-audit/internal/httpclient" + "github.com/P4ST4S/mcp-audit/internal/mcp" "github.com/P4ST4S/mcp-audit/internal/middleware" ) @@ -111,6 +113,167 @@ func TestHTTPProxyAuthorizationForwardingMigrationScenario(t *testing.T) { } } +func TestHTTPProxyForwardsValidMCP2026RequestUnchanged(t *testing.T) { + body := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"protocolRevision":"2026-07-28"},"requestState":{"round":2},"cache":{"ttl":60}}}`) + var upstreamBody []byte + var upstreamHeaders http.Header + proxy, err := NewHTTPProxy(HTTPConfig{ + Upstream: "http://upstream.local", + Audit: testAuditLogger(), + }) + if err != nil { + t.Fatalf("new http proxy: %v", err) + } + proxy.client.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { + upstreamBody, _ = io.ReadAll(r.Body) + upstreamHeaders = r.Header.Clone() + return okJSONResponse(), nil + }) + + req := httptest.NewRequest(http.MethodPost, "http://proxy.local/mcp", bytes.NewReader(body)) + req.Header.Set(mcp.HeaderMethod, "tools/list") + req.Header.Set(mcp.HeaderProtocolVersion, "2026-07-28") + req.Header.Set("Mcp-Session-Id", "session-123") + rec := httptest.NewRecorder() + proxy.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !bytes.Equal(upstreamBody, body) { + t.Fatalf("upstream body = %s, want %s", upstreamBody, body) + } + for header, want := range map[string]string{ + mcp.HeaderMethod: "tools/list", + mcp.HeaderProtocolVersion: "2026-07-28", + "Mcp-Session-Id": "session-123", + } { + if got := upstreamHeaders.Get(header); got != want { + t.Fatalf("%s = %q, want %q", header, got, want) + } + } +} + +func TestHTTPProxyRejectsInconsistentMCPMetadata(t *testing.T) { + body := []byte(`{"jsonrpc":"2.0","id":"call-1","method":"tools/call","params":{"name":"delete_file"}}`) + cases := []struct { + name string + headers http.Header + }{ + {name: "method", headers: http.Header{mcp.HeaderMethod: {"resources/read"}}}, + {name: "name", headers: http.Header{mcp.HeaderName: {"read_file"}}}, + {name: "revision", headers: http.Header{mcp.HeaderProtocolVersion: {"2099-01-01"}}}, + {name: "duplicate", headers: http.Header{mcp.HeaderMethod: {"tools/call", "resources/read"}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + upstreamCalls := 0 + proxy, err := NewHTTPProxy(HTTPConfig{Upstream: "http://upstream.local"}) + if err != nil { + t.Fatalf("new http proxy: %v", err) + } + proxy.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + upstreamCalls++ + return okJSONResponse(), nil + }) + req := httptest.NewRequest(http.MethodPost, "http://proxy.local/mcp", bytes.NewReader(body)) + req.Header = tc.headers + rec := httptest.NewRecorder() + proxy.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if upstreamCalls != 0 { + t.Fatalf("upstream calls = %d, want 0", upstreamCalls) + } + var response struct { + ID string `json:"id"` + Error *audit.RPCError `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.ID != "call-1" || response.Error == nil || response.Error.Code != -32600 { + t.Fatalf("response = %#v", response) + } + if got := rec.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("Cache-Control = %q", got) + } + }) + } +} + +func TestHTTPProxyForwardsProtocolOnlyStreamableGET(t *testing.T) { + var upstreamMethod string + var protocolVersion string + proxy, err := NewHTTPProxy(HTTPConfig{Upstream: "http://upstream.local"}) + if err != nil { + t.Fatalf("new http proxy: %v", err) + } + proxy.client.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { + upstreamMethod = r.Method + protocolVersion = r.Header.Get(mcp.HeaderProtocolVersion) + return &http.Response{StatusCode: http.StatusAccepted, Body: io.NopCloser(bytes.NewReader(nil)), Header: make(http.Header)}, nil + }) + req := httptest.NewRequest(http.MethodGet, "http://proxy.local/mcp", nil) + req.Header.Set(mcp.HeaderProtocolVersion, "2026-07-28") + rec := httptest.NewRecorder() + proxy.ServeHTTP(rec, req) + + if rec.Code != http.StatusAccepted || upstreamMethod != http.MethodGet || protocolVersion != "2026-07-28" { + t.Fatalf("status/method/revision = %d/%q/%q", rec.Code, upstreamMethod, protocolVersion) + } +} + +func TestHTTPProxyStreamsMCP2026SSEUnchanged(t *testing.T) { + event := "event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\"}\n\n" + proxy, err := NewHTTPProxy(HTTPConfig{Upstream: "http://upstream.local"}) + if err != nil { + t.Fatalf("new http proxy: %v", err) + } + proxy.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(event)), + Header: http.Header{ + "Content-Type": {"text/event-stream"}, + "Mcp-Session-Id": {"session-123"}, + }, + }, nil + }) + req := httptest.NewRequest(http.MethodGet, "http://proxy.local/mcp", nil) + req.Header.Set(mcp.HeaderProtocolVersion, "2026-07-28") + rec := httptest.NewRecorder() + proxy.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK || rec.Body.String() != event { + t.Fatalf("status/body = %d/%q", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Mcp-Session-Id"); got != "session-123" { + t.Fatalf("Mcp-Session-Id = %q", got) + } +} + +func TestHTTPProxyPreservesLegacyUninspectableTraffic(t *testing.T) { + upstreamCalls := 0 + proxy, err := NewHTTPProxy(HTTPConfig{Upstream: "http://upstream.local"}) + if err != nil { + t.Fatalf("new http proxy: %v", err) + } + proxy.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + upstreamCalls++ + return okJSONResponse(), nil + }) + req := httptest.NewRequest(http.MethodPost, "http://proxy.local/mcp", bytes.NewReader([]byte("not-json-rpc"))) + rec := httptest.NewRecorder() + proxy.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK || upstreamCalls != 1 { + t.Fatalf("status/upstream calls = %d/%d, want 200/1", rec.Code, upstreamCalls) + } +} + func TestHTTPProxyForwardHeadersAreCaseInsensitive(t *testing.T) { var upstreamHeaders http.Header proxy, err := NewHTTPProxy(HTTPConfig{