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
8 changes: 7 additions & 1 deletion pkg/audit/auditor.go
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,13 @@ func (*Auditor) detectApplicationError(rw *responseWriter) *mcp.ParsedMCPRespons
prefix = rw.errorDetectionBody.Bytes()
}
if len(prefix) > 0 && prefix[0] == '{' {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Large errors are still missed when IncludeResponseData is on. In that case errorDetectionBody isn't allocated (L269) and detection reuses rw.body, but rw.body only takes a write if the whole thing fits under MaxDataSize (L141, 1024 by default). A single 1500-byte error write leaves rw.body empty, so the prefix is empty, the fallback never runs, and the event stays success. A response written in several chunks can also leave a truncated rw.body shorter than 512 bytes, which then skips the len(prefix) >= errorDetectionBufferSize check below.

I think the simplest fix is to always allocate errorDetectionBody regardless of IncludeResponseData (it's 512 bytes per request) and stop reusing rw.body for detection. A test with IncludeResponseData: true and a body larger than MaxDataSize would cover it. Since #6720 is about any error body over the buffer size, I'd like to see this fixed in this PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related, though it predates this PR: the prefix[0] == '{' check rejects bodies with leading whitespace, which is still valid JSON. Something like \n{"jsonrpc":"2.0",...,"error":{...}} is never inspected, whatever its size. The new walker would handle it fine since the decoder skips whitespace; it's only this guard that rejects it. Trimming with bytes.TrimLeft(prefix, " \t\r\n") before the check would fix it. A follow-up is fine if you'd rather keep this PR narrow.

return mcp.ParseMCPResponse(prefix)
parsed := mcp.ParseMCPResponse(prefix)
// A full buffer may be a cut-off response, which does not unmarshal.
// Fall back to a token walk that only needs the bytes before the cut.
if !parsed.HasError && len(prefix) >= errorDetectionBufferSize {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ParseMCPResponse has only this one caller, and the token walk gives the same answer on complete bodies. So I think detectApplicationError could just call the walker unconditionally (or make the walker the implementation of ParseMCPResponse) and drop the size check and the Unmarshal envelope. That also removes the case where a truncated prefix shorter than 512 bytes never reaches the fallback.

The behavior differences (case-sensitive key match, the first duplicate error key wins, a non-integer code gives HasError with code 0) look fine to me, arguably closer to the spec. If you want to keep both parsers, a comment explaining why there are two would help.

return mcp.ParseTruncatedMCPResponse(prefix)
}
return parsed
}
return nil
}
Expand Down
57 changes: 57 additions & 0 deletions pkg/audit/auditor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1253,6 +1253,63 @@ func TestMiddlewareDetectsJSONRPCErrors(t *testing.T) {
assert.Contains(t, logOutput, "jsonrpc_error_code")
})

t.Run("detects JSON-RPC error in a body larger than the detection buffer", func(t *testing.T) {
t.Parallel()
// Bodies of 511, 512 and 513+ bytes: the old prefix parse missed
// everything past the buffer size.
for _, size := range []int{errorDetectionBufferSize - 1, errorDetectionBufferSize, errorDetectionBufferSize + 1, 4 * errorDetectionBufferSize} {
var logBuf bytes.Buffer
detectErrors := true
config := &Config{DetectApplicationErrors: &detectErrors}
auditor, err := NewAuditorWithTransport(config, "streamable-http")
require.NoError(t, err)
auditor.auditLogger = NewAuditLogger(&logBuf)

const envelope = `{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":""}}`
errorResponse := `{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"` +
strings.Repeat("a", size-len(envelope)) + `"}}`
require.Len(t, errorResponse, size)
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte(errorResponse))
require.NoError(t, err)
})

req := httptest.NewRequest("POST", "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":"1","method":"tools/call","params":{"name":"test"}}`))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
auditor.Middleware(handler).ServeHTTP(rr, req)

assert.Equal(t, errorResponse, rr.Body.String(), "size %d", size)
assert.Contains(t, logBuf.String(), OutcomeApplicationError, "size %d", size)
assert.Contains(t, logBuf.String(), "-32000", "size %d", size)
}
})

t.Run("keeps outcome=success for a large JSON-RPC result", func(t *testing.T) {
t.Parallel()
var logBuf bytes.Buffer
detectErrors := true
config := &Config{DetectApplicationErrors: &detectErrors}
auditor, err := NewAuditorWithTransport(config, "streamable-http")
require.NoError(t, err)
auditor.auditLogger = NewAuditLogger(&logBuf)

response := `{"jsonrpc":"2.0","id":1,"result":{"error":{"code":1,"message":"x"},"text":"` +
strings.Repeat("a", 4*errorDetectionBufferSize) + `"}}`
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte(response))
require.NoError(t, err)
})

req := httptest.NewRequest("POST", "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":"1","method":"tools/call","params":{"name":"test"}}`))
req.Header.Set("Content-Type", "application/json")
auditor.Middleware(handler).ServeHTTP(httptest.NewRecorder(), req)

assert.NotContains(t, logBuf.String(), OutcomeApplicationError)
})

t.Run("keeps outcome=success for valid JSON-RPC result", func(t *testing.T) {
t.Parallel()
var logBuf bytes.Buffer
Expand Down
70 changes: 70 additions & 0 deletions pkg/mcp/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package mcp

import (
"bytes"
"encoding/json"
)

Expand Down Expand Up @@ -60,3 +61,72 @@
ErrorMessage: envelope.Error.Message,
}
}

// ParseTruncatedMCPResponse detects a top-level JSON-RPC "error" object in a
// response body that may have been cut off, such as a fixed-size prefix of a
// larger response. ParseMCPResponse cannot do this because a truncated JSON
// document does not unmarshal.
//
// It walks the top-level object token by token and stops at the "error" key,
// so only the bytes before the cut matter. "code" and "message" are filled in
// when they were read completely; otherwise they keep their zero values and
// HasError is still true. Values of other top-level keys are skipped, and
// nested "error" keys are ignored. Like ParseMCPResponse it is lenient and
// returns HasError=false when the body is not a JSON object or has no
// top-level "error" object before the cut.
func ParseTruncatedMCPResponse(body []byte) *ParsedMCPResponse {

Check failure on line 77 in pkg/mcp/response.go

View workflow job for this annotation

GitHub Actions / Linting / Lint Go Code

cyclomatic complexity 17 of func `ParseTruncatedMCPResponse` is high (> 15) (gocyclo)
dec := json.NewDecoder(bytes.NewReader(body))
if tok, err := dec.Token(); err != nil || tok != json.Delim('{') {
return &ParsedMCPResponse{}
}
for dec.More() {
keyTok, err := dec.Token()
if err != nil {
return &ParsedMCPResponse{}
}
key, ok := keyTok.(string)
if !ok {
return &ParsedMCPResponse{}
}
if key != "error" {
var skip json.RawMessage
if err := dec.Decode(&skip); err != nil {
return &ParsedMCPResponse{}
}
continue
}
if tok, err := dec.Token(); err != nil || tok != json.Delim('{') {
// "error": null or a non-object value is not a JSON-RPC error.
return &ParsedMCPResponse{}
}
res := &ParsedMCPResponse{HasError: true}
for dec.More() {
fieldTok, err := dec.Token()
if err != nil {
return res
}
field, _ := fieldTok.(string)
switch field {
case "code":
var code int
if err := dec.Decode(&code); err != nil {
return res
}
res.ErrorCode = code
Comment on lines +111 to +115

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

json.Decoder accepts a number that ends at EOF, so if the 512-byte cut lands inside the code digits this returns a truncated value. A prefix ending in ..."error":{"code":-320 (really -32603) gives HasError=true, ErrorCode=-320, and that gets logged as if it were the real code. It also contradicts the doc comment above, which says code is only filled in when it was read completely.

Could you keep the code only if something follows it, e.g. dec.InputOffset() < int64(len(body)), and add a table case with a cut in the middle of the number that expects ErrorCode 0? You can only hit this when the id or other fields push code up to the edge, but when it happens the audit record is quietly wrong.

case "message":
var msg string
if err := dec.Decode(&msg); err != nil {
return res
}
res.ErrorMessage = msg
default:
var skip json.RawMessage
if err := dec.Decode(&skip); err != nil {
return res
}
}
}
return res
}
return &ParsedMCPResponse{}
}
86 changes: 86 additions & 0 deletions pkg/mcp/response_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,89 @@ func TestParseMCPResponse(t *testing.T) {
})
}
}

func TestParseTruncatedMCPResponse(t *testing.T) {
t.Parallel()

full := `{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"` + strings.Repeat("a", 600) + `"}}`

tests := []struct {
name string
body string
wantHasError bool
wantCode int
wantMessage string
}{
{
name: "complete error",
body: `{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"boom"}}`,
wantHasError: true,
wantCode: -32603,
wantMessage: "boom",
},
{
name: "cut inside message string keeps code",
body: full[:200],
wantHasError: true,
wantCode: -32000,
},
{
name: "cut right after code",
body: `{"jsonrpc":"2.0","id":1,"error":{"code":-32000,`,
wantHasError: true,
wantCode: -32000,
},
{
name: "cut inside the error key name",
body: `{"jsonrpc":"2.0","id":1,"err`,
wantHasError: false,
},
{
name: "cut right after the error key opens",
body: `{"jsonrpc":"2.0","id":1,"error":{`,
wantHasError: true,
},
{
name: "data before message is skipped",
body: `{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"data":{"a":[1,2]},"message":"late"}}`,
wantHasError: true,
wantCode: -32000,
wantMessage: "late",
},
{
name: "truncated result is not an error",
body: `{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"` + strings.Repeat("a", 600),
wantHasError: false,
},
{
name: "nested error key is ignored",
body: `{"jsonrpc":"2.0","id":1,"result":{"error":{"code":1,"message":"x"},"pad":"` + strings.Repeat("a", 600),
wantHasError: false,
},
{
name: "null error",
body: `{"jsonrpc":"2.0","id":1,"error":null}`,
wantHasError: false,
},
{
name: "not an object",
body: `[{"error":{"code":1}}`,
wantHasError: false,
},
{
name: "empty",
body: "",
wantHasError: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := ParseTruncatedMCPResponse([]byte(tt.body))
assert.Equal(t, tt.wantHasError, got.HasError)
assert.Equal(t, tt.wantCode, got.ErrorCode)
assert.Equal(t, tt.wantMessage, got.ErrorMessage)
})
}
}
Loading