Skip to content

fix: [mcp-gateway] 修复unla因未按Accept-Encoding解压 HTTP 响应引发的解析乱码问题 #234#302

Merged
iFurySt merged 1 commit intoAmoyLab:mainfrom
zwl7:main
Mar 5, 2026
Merged

fix: [mcp-gateway] 修复unla因未按Accept-Encoding解压 HTTP 响应引发的解析乱码问题 #234#302
iFurySt merged 1 commit intoAmoyLab:mainfrom
zwl7:main

Conversation

@zwl7
Copy link

@zwl7 zwl7 commented Feb 28, 2026

修复说明

Fixes #234

修复逻辑

  1. 使用过程中发现mcp-gateway解析有压缩格式的http响应的场景下,会解析乱码,查看源代码发现go这块缺少对压缩格式数据的处理,因此补充了一下对解压响应模块的逻辑,增加了gzip,br,zstd,deflate 4种常见压缩方式的支持

测试验证

  • 环境:Go 1.24.1 + Linux
  • 验证步骤:增加了6个测试用例
image

测试用例验证 执行 go test -v ./internal/core/... 已通过测试
image

全量测试验证 执行 make test 已通过

流程验证 基于修改后的代码重新打包一个新的mcp-gateway镜像,在对应web ui的页面上的配置有压缩方式的http接口,不需要手动配置“Accept-Encoding: identity” 响应头也可以成功解析有压缩格式的响应
image

image

Summary by Sourcery

Handle HTTP tool responses that use compressed encodings and ensure downstream consumers always see decoded bodies.

Bug Fixes:

  • Fix garbled parsing of HTTP responses by decoding bodies according to the Content-Encoding header before further processing.
  • Treat invalid gzip payloads as errors instead of returning corrupted data.

Enhancements:

  • Support gzip, brotli, zstd, and deflate (including raw deflate streams) when decoding HTTP response bodies for tools.
  • Normalize decoded responses by stripping Content-Encoding and Content-Length headers after decompression so downstream handling is consistent.

Build:

  • Add brotli and klauspost/compress libraries as indirect dependencies for HTTP response decompression.

Tests:

  • Add unit tests covering gzip, brotli, zstd, and deflate HTTP responses and the decoding helper, including raw deflate and invalid gzip scenarios.

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Feb 28, 2026

Reviewer's Guide

Adds transparent decompression of HTTP responses in mcp-gateway’s HTTP tool execution path, supporting gzip, brotli, zstd, and deflate encodings, and introduces tests to validate decoding behavior and error handling.

Sequence diagram for HTTP tool response decompression in executeHTTPTool

sequenceDiagram
    actor User
    participant MCPGateway
    participant HTTPClient
    participant HTTPServer

    User->>MCPGateway: Trigger HTTP tool
    MCPGateway->>HTTPClient: Send HTTP request
    HTTPClient->>HTTPServer: HTTP request
    HTTPServer-->>HTTPClient: HTTP response
    HTTPClient-->>MCPGateway: *http.Response (possibly compressed)

    MCPGateway->>MCPGateway: readDecodedResponseBody(resp)
    activate MCPGateway
    MCPGateway->>MCPGateway: io.ReadAll(resp.Body)
    MCPGateway->>MCPGateway: parseContentEncodings(resp.Header.Content-Encoding)
    loop For each encoding in reverse order
        MCPGateway->>MCPGateway: decodeBodyBytesByEncoding(body, encoding)
        alt Supported encoding (gzip, br, zstd, deflate)
            MCPGateway->>MCPGateway: Decode body to plain bytes
        else Unsupported encoding
            MCPGateway-->>MCPGateway: Error
        end
    end
    MCPGateway-->>MCPGateway: Decoded respBodyBytes
    deactivate MCPGateway

    MCPGateway->>MCPGateway: resp.Body = io.NopCloser(decodedBody)
    MCPGateway->>MCPGateway: resp.Header.Del(Content-Encoding)
    MCPGateway->>MCPGateway: resp.Header.Del(Content-Length)

    MCPGateway->>User: Return parsed HTTP tool result using decoded body
Loading

File-Level Changes

Change Details Files
Introduce reusable helpers to parse and decode compressed HTTP response bodies based on Content-Encoding, and wire them into executeHTTPTool.
  • Add parseContentEncodings to normalize and filter Content-Encoding header values, ignoring empty and identity encodings.
  • Add decodeBodyBytesByEncoding to handle gzip/x-gzip, brotli, zstd, and deflate (zlib and raw deflate) decoding with proper reader lifecycle and error wrapping.
  • Add readDecodedResponseBody to read the raw body, then apply the encodings in reverse order to support stacked/composite encodings, returning decoded bytes or an error.
  • Update executeHTTPTool to use readDecodedResponseBody, then replace resp.Body with a reader over the decoded bytes and clear Content-Encoding and Content-Length headers to reflect the transformed body.
internal/core/tool.go
Add tests covering compressed HTTP responses and decoding edge cases for the HTTP tool.
  • Add TestExecuteHTTPTool_GzipResponse to verify that gzip-compressed JSON responses are decoded and exposed correctly in tool results when Accept-Encoding: gzip is used.
  • Add TestReadDecodedResponseBody_GzipInvalidData to assert error behavior when gzip decoding is attempted on invalid data.
  • Add TestExecuteHTTPTool_BrotliResponse, TestExecuteHTTPTool_ZstdResponse, and TestExecuteHTTPTool_DeflateResponse to validate decoding and response propagation for br, zstd, and deflate encodings.
  • Add TestReadDecodedResponseBody_DeflateRaw to verify raw deflate (non-zlib wrapped) handling for Content-Encoding: deflate.
internal/core/execute_tool_test.go
Declare new third-party compression libraries for brotli and zstd support.
  • Add github.com/andybalholm/brotli as an indirect dependency for brotli decoding.
  • Add github.com/klauspost/compress (used for zstd) as an indirect dependency.
  • Update go.sum accordingly to lock the new modules.
go.mod
go.sum

Assessment against linked issues

Issue Objective Addressed Explanation
#234 Add support in the mcp-gateway HTTP tool for correctly decoding gzip-compressed HTTP responses (based on Content-Encoding), so responses are not garbled and users no longer need to force "Accept-Encoding: identity".

Possibly linked issues

  • [功能] 请支持gzip #234: They address the same bug: unla not decoding gzip HTTP responses, causing garbled output; PR also adds other encodings.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 3 issues, and left some high level feedback:

  • In decodeBodyBytesByEncoding, the raw deflate fallback uses flate.NewReader(bytes.NewReader(body)) without checking the returned error, which can lead to panics or misleading decode errors if the raw stream is invalid; capture and handle the error similarly to the zlib branch.
  • For unsupported Content-Encoding values, decodeBodyBytesByEncoding currently fails the entire HTTP tool execution; consider falling back to returning the original body with a logged warning to avoid breaking tools on unknown or custom encodings.
  • You added support for multiple content encodings via parseContentEncodings and reverse-order decoding; it would be good to ensure this works for stacked encodings (e.g., gzip, br) in real-world usage and that the order matches RFC semantics.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `decodeBodyBytesByEncoding`, the raw deflate fallback uses `flate.NewReader(bytes.NewReader(body))` without checking the returned error, which can lead to panics or misleading decode errors if the raw stream is invalid; capture and handle the error similarly to the zlib branch.
- For unsupported `Content-Encoding` values, `decodeBodyBytesByEncoding` currently fails the entire HTTP tool execution; consider falling back to returning the original body with a logged warning to avoid breaking tools on unknown or custom encodings.
- You added support for multiple content encodings via `parseContentEncodings` and reverse-order decoding; it would be good to ensure this works for stacked encodings (e.g., `gzip, br`) in real-world usage and that the order matches RFC semantics.

## Individual Comments

### Comment 1
<location path="internal/core/tool.go" line_range="68-77" />
<code_context>
+		closeFn = gzReader.Close
+	case "br":
+		reader = brotli.NewReader(reader)
+	case "zstd":
+		zstdReader, err := zstd.NewReader(reader)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create zstd reader: %w", err)
+		}
+		reader = zstdReader
+		closeFn = func() error {
+			zstdReader.Close()
+			return nil
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Preserve and surface errors from zstdReader.Close instead of discarding them

In the `zstd` branch, `zstdReader.Close()` is wrapped in a `closeFn` that always returns nil, so any close error is silently dropped while `closeErr` is still being checked by callers. To align with the other codecs and preserve potential failures, you can wire the close error through:

```go
case "zstd":
	zstdReader, err := zstd.NewReader(reader)
	if err != nil {
		return nil, fmt.Errorf("failed to create zstd reader: %w", err)
	}
	reader = zstdReader
	closeFn = func() error {
		return zstdReader.Close()
	}
```

This keeps error handling consistent and ensures zstd close errors are observable to callers.

```suggestion
	case "zstd":
		zstdReader, err := zstd.NewReader(reader)
		if err != nil {
			return nil, fmt.Errorf("failed to create zstd reader: %w", err)
		}
		reader = zstdReader
		closeFn = func() error {
			return zstdReader.Close()
		}
```
</issue_to_address>

### Comment 2
<location path="internal/core/tool.go" line_range="444-447" />
<code_context>
 	}

-	// Restore response body for further processing
+	// Restore decoded response body for further processing.
 	resp.Body = io.NopCloser(bytes.NewBuffer(respBodyBytes))
+	resp.Header.Del("Content-Encoding")
+	resp.Header.Del("Content-Length")

 	// Log response status
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Consider updating resp.ContentLength alongside the Content-Length header after decoding

Right now the `resp.ContentLength` field still reflects the original (encoded) length, so it can disagree with the decoded body you’ve set on `resp.Body`. Any caller using `resp.ContentLength` will see the wrong value. After decoding, also update the field, e.g.:

```go
resp.ContentLength = int64(len(respBodyBytes)) // or -1 if you want it treated as unknown
```

```suggestion
	// Restore decoded response body for further processing.
	resp.Body = io.NopCloser(bytes.NewBuffer(respBodyBytes))
	resp.ContentLength = int64(len(respBodyBytes))
	resp.Header.Del("Content-Encoding")
	resp.Header.Del("Content-Length")
```
</issue_to_address>

### Comment 3
<location path="internal/core/execute_tool_test.go" line_range="124-81" />
<code_context>
+	}
+}
+
+func TestReadDecodedResponseBody_GzipInvalidData(t *testing.T) {
+	resp := &http.Response{
+		Header: http.Header{"Content-Encoding": []string{"gzip"}},
+		Body:   io.NopCloser(bytes.NewBufferString("not-gzip-data")),
+	}
+
+	_, err := readDecodedResponseBody(resp)
+	assert.Error(t, err)
+}
+
</code_context>
<issue_to_address>
**suggestion (testing):** Add coverage for multiple Content-Encoding values and encoding order in readDecodedResponseBody

Since readDecodedResponseBody handles multiple encodings by iterating them in reverse, please add at least one test with a chained Content-Encoding (e.g. `gzip, br` and/or `br, gzip`) to verify that: (1) decoding happens in the correct order, and (2) errors from any step in the chain are surfaced. This will validate the reverse-iteration logic and protect against regressions for multi-encoding responses.

Suggested implementation:

```golang
func TestReadDecodedResponseBody_GzipInvalidData(t *testing.T) {
	resp := &http.Response{
		Header: http.Header{"Content-Encoding": []string{"gzip"}},
		Body:   io.NopCloser(bytes.NewBufferString("not-gzip-data")),
	}

	_, err := readDecodedResponseBody(resp)
	assert.Error(t, err)
}

func encodeWithGzipThenZlib(t *testing.T, data []byte) []byte {
	var gzipBuf bytes.Buffer
	gzipWriter := gzip.NewWriter(&gzipBuf)
	_, err := gzipWriter.Write(data)
	if err != nil {
		t.Fatalf("failed to gzip-compress data: %v", err)
	}
	if err := gzipWriter.Close(); err != nil {
		t.Fatalf("failed to close gzip writer: %v", err)
	}

	var zlibBuf bytes.Buffer
	zlibWriter := zlib.NewWriter(&zlibBuf)
	_, err = zlibWriter.Write(gzipBuf.Bytes())
	if err != nil {
		t.Fatalf("failed to zlib-compress data: %v", err)
	}
	if err := zlibWriter.Close(); err != nil {
		t.Fatalf("failed to close zlib writer: %v", err)
	}

	return zlibBuf.Bytes()
}

func TestReadDecodedResponseBody_MultiEncoding_GzipThenDeflate_Success(t *testing.T) {
	original := []byte(`{"hello":"multi"}`)
	encoded := encodeWithGzipThenZlib(t, original)

	resp := &http.Response{
		Header: http.Header{
			// Encodings are listed in the order they were applied.
			// Body is zlib(deflate(gzip(original))).
			"Content-Encoding": []string{"gzip", "deflate"},
		},
		Body: io.NopCloser(bytes.NewReader(encoded)),
	}

	body, err := readDecodedResponseBody(resp)
	if assert.NoError(t, err) {
		assert.Equal(t, original, body)
	}
}

func TestReadDecodedResponseBody_MultiEncoding_ErrorInInnerEncoding(t *testing.T) {
	// Construct a body that can be successfully zlib-decoded, but is not valid gzip,
	// so that the second decoding step fails.
	var zlibBuf bytes.Buffer
	zlibWriter := zlib.NewWriter(&zlibBuf)
	_, err := zlibWriter.Write([]byte("not-gzip-data"))
	if err != nil {
		t.Fatalf("failed to zlib-compress data: %v", err)
	}
	if err := zlibWriter.Close(); err != nil {
		t.Fatalf("failed to close zlib writer: %v", err)
	}

	resp := &http.Response{
		Header: http.Header{
			// readDecodedResponseBody should decode in reverse order:
			// first "deflate" (zlib), then "gzip". The second step should fail.
			"Content-Encoding": []string{"gzip", "deflate"},
		},
		Body: io.NopCloser(bytes.NewReader(zlibBuf.Bytes())),
	}

	_, err = readDecodedResponseBody(resp)
	assert.Error(t, err)
}

```

These tests assume that `readDecodedResponseBody` treats `"deflate"` as zlib-wrapped data and that it iterates the encodings slice in reverse order, as suggested by your comment. If the implementation instead:
1. Uses `compress/flate` directly for `"deflate"`, or
2. Parses a single header value like `"gzip, deflate"` rather than `[]string{"gzip", "deflate"}`,

you should adjust:
- The encoder (`encodeWithGzipThenZlib`) to match the actual `"deflate"` implementation (e.g., using `flate.NewWriter` instead of `zlib.NewWriter`), and/or
- The `Content-Encoding` header construction to mirror how your code reads/normalizes multiple encodings (e.g., `Header: http.Header{"Content-Encoding": []string{"gzip, deflate"}}`).
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +68 to +77
case "zstd":
zstdReader, err := zstd.NewReader(reader)
if err != nil {
return nil, fmt.Errorf("failed to create zstd reader: %w", err)
}
reader = zstdReader
closeFn = func() error {
zstdReader.Close()
return nil
}
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion (bug_risk): Preserve and surface errors from zstdReader.Close instead of discarding them

In the zstd branch, zstdReader.Close() is wrapped in a closeFn that always returns nil, so any close error is silently dropped while closeErr is still being checked by callers. To align with the other codecs and preserve potential failures, you can wire the close error through:

case "zstd":
	zstdReader, err := zstd.NewReader(reader)
	if err != nil {
		return nil, fmt.Errorf("failed to create zstd reader: %w", err)
	}
	reader = zstdReader
	closeFn = func() error {
		return zstdReader.Close()
	}

This keeps error handling consistent and ensures zstd close errors are observable to callers.

Suggested change
case "zstd":
zstdReader, err := zstd.NewReader(reader)
if err != nil {
return nil, fmt.Errorf("failed to create zstd reader: %w", err)
}
reader = zstdReader
closeFn = func() error {
zstdReader.Close()
return nil
}
case "zstd":
zstdReader, err := zstd.NewReader(reader)
if err != nil {
return nil, fmt.Errorf("failed to create zstd reader: %w", err)
}
reader = zstdReader
closeFn = func() error {
return zstdReader.Close()
}

Comment on lines +444 to +447
// Restore decoded response body for further processing.
resp.Body = io.NopCloser(bytes.NewBuffer(respBodyBytes))
resp.Header.Del("Content-Encoding")
resp.Header.Del("Content-Length")
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion (bug_risk): Consider updating resp.ContentLength alongside the Content-Length header after decoding

Right now the resp.ContentLength field still reflects the original (encoded) length, so it can disagree with the decoded body you’ve set on resp.Body. Any caller using resp.ContentLength will see the wrong value. After decoding, also update the field, e.g.:

resp.ContentLength = int64(len(respBodyBytes)) // or -1 if you want it treated as unknown
Suggested change
// Restore decoded response body for further processing.
resp.Body = io.NopCloser(bytes.NewBuffer(respBodyBytes))
resp.Header.Del("Content-Encoding")
resp.Header.Del("Content-Length")
// Restore decoded response body for further processing.
resp.Body = io.NopCloser(bytes.NewBuffer(respBodyBytes))
resp.ContentLength = int64(len(respBodyBytes))
resp.Header.Del("Content-Encoding")
resp.Header.Del("Content-Length")

@@ -74,3 +81,166 @@ func TestExecuteHTTPTool_ForwardHeadersAndRequestError(t *testing.T) {
assert.Error(t, err)
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion (testing): Add coverage for multiple Content-Encoding values and encoding order in readDecodedResponseBody

Since readDecodedResponseBody handles multiple encodings by iterating them in reverse, please add at least one test with a chained Content-Encoding (e.g. gzip, br and/or br, gzip) to verify that: (1) decoding happens in the correct order, and (2) errors from any step in the chain are surfaced. This will validate the reverse-iteration logic and protect against regressions for multi-encoding responses.

Suggested implementation:

func TestReadDecodedResponseBody_GzipInvalidData(t *testing.T) {
	resp := &http.Response{
		Header: http.Header{"Content-Encoding": []string{"gzip"}},
		Body:   io.NopCloser(bytes.NewBufferString("not-gzip-data")),
	}

	_, err := readDecodedResponseBody(resp)
	assert.Error(t, err)
}

func encodeWithGzipThenZlib(t *testing.T, data []byte) []byte {
	var gzipBuf bytes.Buffer
	gzipWriter := gzip.NewWriter(&gzipBuf)
	_, err := gzipWriter.Write(data)
	if err != nil {
		t.Fatalf("failed to gzip-compress data: %v", err)
	}
	if err := gzipWriter.Close(); err != nil {
		t.Fatalf("failed to close gzip writer: %v", err)
	}

	var zlibBuf bytes.Buffer
	zlibWriter := zlib.NewWriter(&zlibBuf)
	_, err = zlibWriter.Write(gzipBuf.Bytes())
	if err != nil {
		t.Fatalf("failed to zlib-compress data: %v", err)
	}
	if err := zlibWriter.Close(); err != nil {
		t.Fatalf("failed to close zlib writer: %v", err)
	}

	return zlibBuf.Bytes()
}

func TestReadDecodedResponseBody_MultiEncoding_GzipThenDeflate_Success(t *testing.T) {
	original := []byte(`{"hello":"multi"}`)
	encoded := encodeWithGzipThenZlib(t, original)

	resp := &http.Response{
		Header: http.Header{
			// Encodings are listed in the order they were applied.
			// Body is zlib(deflate(gzip(original))).
			"Content-Encoding": []string{"gzip", "deflate"},
		},
		Body: io.NopCloser(bytes.NewReader(encoded)),
	}

	body, err := readDecodedResponseBody(resp)
	if assert.NoError(t, err) {
		assert.Equal(t, original, body)
	}
}

func TestReadDecodedResponseBody_MultiEncoding_ErrorInInnerEncoding(t *testing.T) {
	// Construct a body that can be successfully zlib-decoded, but is not valid gzip,
	// so that the second decoding step fails.
	var zlibBuf bytes.Buffer
	zlibWriter := zlib.NewWriter(&zlibBuf)
	_, err := zlibWriter.Write([]byte("not-gzip-data"))
	if err != nil {
		t.Fatalf("failed to zlib-compress data: %v", err)
	}
	if err := zlibWriter.Close(); err != nil {
		t.Fatalf("failed to close zlib writer: %v", err)
	}

	resp := &http.Response{
		Header: http.Header{
			// readDecodedResponseBody should decode in reverse order:
			// first "deflate" (zlib), then "gzip". The second step should fail.
			"Content-Encoding": []string{"gzip", "deflate"},
		},
		Body: io.NopCloser(bytes.NewReader(zlibBuf.Bytes())),
	}

	_, err = readDecodedResponseBody(resp)
	assert.Error(t, err)
}

These tests assume that readDecodedResponseBody treats "deflate" as zlib-wrapped data and that it iterates the encodings slice in reverse order, as suggested by your comment. If the implementation instead:

  1. Uses compress/flate directly for "deflate", or
  2. Parses a single header value like "gzip, deflate" rather than []string{"gzip", "deflate"},

you should adjust:

  • The encoder (encodeWithGzipThenZlib) to match the actual "deflate" implementation (e.g., using flate.NewWriter instead of zlib.NewWriter), and/or
  • The Content-Encoding header construction to mirror how your code reads/normalizes multiple encodings (e.g., Header: http.Header{"Content-Encoding": []string{"gzip, deflate"}}).

@zwl7 zwl7 mentioned this pull request Mar 2, 2026
2 tasks
@zwl7
Copy link
Author

zwl7 commented Mar 4, 2026

@iFurySt @LeoLiuYan 作者你好,麻烦有空审批一下此PR,我看微信交流群有不少人遇到这个bug了,谢谢!

Copy link
Member

@iFurySt iFurySt left a comment

Choose a reason for hiding this comment

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

LGTM. Thanks!

@iFurySt iFurySt merged commit 8182e39 into AmoyLab:main Mar 5, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[功能] 请支持gzip

2 participants