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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`.
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
3 changes: 3 additions & 0 deletions STABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 15 additions & 4 deletions internal/mcp/metadata.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package mcp

import (
"bytes"
"encoding/json"
"fmt"
"net/http"
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions internal/mcp/metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
39 changes: 39 additions & 0 deletions internal/proxy/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
163 changes: 163 additions & 0 deletions internal/proxy/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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{
Expand Down
Loading