This is an automated email from the ASF dual-hosted git repository.

Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git


The following commit(s) were added to refs/heads/master by this push:
     new 50a25f53f1 fix(plugin): bound MCP tool-response capture memory (#7646) 
(#7671)
50a25f53f1 is described below

commit 50a25f53f13327812b7298a0f6d4c73fbc769d6d
Author: Daming <[email protected]>
AuthorDate: Sat Oct 17 20:47:28 2026 +0800

    fix(plugin): bound MCP tool-response capture memory (#7646) (#7671)
    
    The Streamable HTTP MCP response decorator accumulated the whole backend
    response in a StringBuilder and decoded every chunk with new String(bytes,
    UTF_8). That grew memory without bound and corrupted multi-byte UTF-8
    sequences split across chunk boundaries.
    
    Bring it to parity with the SSE variant ShenyuMcpResponseDecorator:
    - accumulate raw bytes in a ByteArrayOutputStream and decode UTF-8 exactly
      once at completion, preserving multi-byte characters across boundaries;
    - enforce a 1 MiB capture budget with deterministic over-limit behaviour
      (completeExceptionally) instead of returning a truncated result;
    - always release the incoming buffer, including the over-limit path;
    - make future completion idempotent and share it between writeWith /
      writeAndFlushWith / setComplete via finishResponse().
    
    Add tests for cross-chunk UTF-8 decoding and over-limit failure.
    
    Co-authored-by: Luke Haochao Zhuang <[email protected]>
---
 .../NonCommittingMcpResponseDecorator.java         | 85 +++++++++++++++++-----
 .../NonCommittingMcpResponseDecoratorTest.java     | 38 ++++++++++
 2 files changed, 105 insertions(+), 18 deletions(-)

diff --git 
a/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/response/NonCommittingMcpResponseDecorator.java
 
b/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/response/NonCommittingMcpResponseDecorator.java
index 489ea64ca5..ca607f6c14 100644
--- 
a/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/response/NonCommittingMcpResponseDecorator.java
+++ 
b/shenyu-plugin/shenyu-plugin-mcp-server/src/main/java/org/apache/shenyu/plugin/mcp/server/response/NonCommittingMcpResponseDecorator.java
@@ -29,6 +29,8 @@ import 
org.springframework.http.server.reactive.ServerHttpResponseDecorator;
 import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
 
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
 import java.nio.charset.StandardCharsets;
 import java.util.concurrent.CompletableFuture;
 import java.util.Objects;
@@ -53,13 +55,32 @@ public class NonCommittingMcpResponseDecorator extends 
ServerHttpResponseDecorat
 
     private static final String PLACEHOLDER_SUFFIX = "}";
 
+    /**
+     * Upper bound, in bytes, of the captured backend response for a single 
MCP tool result.
+     * The captured result must be retained in full because the response 
future and the
+     * template contract both require a complete result, so the budget keeps 
memory bounded
+     * instead of growing with the upstream body. Mirrors the SSE variant
+     * {@link ShenyuMcpResponseDecorator} so both protocols share the same 
protection.
+     */
+    private static final int MAX_CAPTURE_BYTES = 1024 * 1024;
+
     private final String sessionId;
 
     private final CompletableFuture<String> responseFuture;
 
     private final JsonObject responseTemplate;
 
-    private final StringBuilder body = new StringBuilder();
+    /**
+     * Accumulates the raw response bytes instead of per-chunk strings, so 
multi-byte UTF-8
+     * sequences that span chunk boundaries are decoded correctly exactly once 
at completion.
+     */
+    private final ByteArrayOutputStream body = new ByteArrayOutputStream();
+
+    /**
+     * Set when the accumulated response exceeds {@link #MAX_CAPTURE_BYTES}; 
further chunks are
+     * dropped and the response future is completed exceptionally with a 
deterministic error.
+     */
+    private volatile boolean captureLimitExceeded;
 
     /**
      * Constructs a new non-committing MCP response decorator.
@@ -87,7 +108,7 @@ public class NonCommittingMcpResponseDecorator extends 
ServerHttpResponseDecorat
         return Flux.from(body)
                 .doOnDiscard(DataBuffer.class, DataBufferUtils::release)
                 .doOnNext(this::appendBuffer)
-                .doOnComplete(this::completeResponseFuture)
+                .doOnComplete(this::finishResponse)
                 .then()
                 .doOnSuccess(aVoid -> LOG.debug("Successfully completed 
writeWith for session: {}", sessionId))
                 .doOnError(error -> handleProcessingError("writeWith", error));
@@ -101,7 +122,7 @@ public class NonCommittingMcpResponseDecorator extends 
ServerHttpResponseDecorat
                 .concatMap(Flux::from)
                 .doOnDiscard(DataBuffer.class, DataBufferUtils::release)
                 .doOnNext(this::appendBuffer)
-                .doOnComplete(this::completeResponseFuture)
+                .doOnComplete(this::finishResponse)
                 .then()
                 .doOnSuccess(aVoid -> LOG.debug("Successfully completed 
writeAndFlushWith for session: {}", sessionId))
                 .doOnError(error -> handleProcessingError("writeAndFlushWith", 
error));
@@ -110,25 +131,43 @@ public class NonCommittingMcpResponseDecorator extends 
ServerHttpResponseDecorat
     @Override
     public Mono<Void> setComplete() {
         LOG.debug("Completing response for session: {}", sessionId);
-        if (!responseFuture.isDone()) {
-            responseFuture.complete(processResponse(""));
-        }
+        finishResponse();
         // Non-committing: do not write to the underlying response.
         return Mono.empty();
     }
 
     /**
-     * Appends a data buffer chunk to the accumulated response body as it is 
emitted,
+     * Appends a data buffer chunk to the accumulated response bytes as it is 
emitted,
      * so that chunks are processed incrementally instead of being buffered as 
a list first.
      *
+     * <p>The raw bytes are retained (never decoded per chunk) so that 
multi-byte UTF-8
+     * sequences spanning chunk boundaries stay intact until the single decode 
at completion.
+     * Once {@link #MAX_CAPTURE_BYTES} would be exceeded, remaining chunks are 
dropped and the
+     * over-limit state is recorded for deterministic failure. The buffer is 
always released.
+     *
      * @param buffer the emitted data buffer chunk
      */
     private void appendBuffer(final DataBuffer buffer) {
         try {
-            final byte[] bytes = new byte[buffer.readableByteCount()];
-            buffer.read(bytes);
             synchronized (body) {
-                body.append(new String(bytes, StandardCharsets.UTF_8));
+                if (captureLimitExceeded) {
+                    return;
+                }
+                try (DataBuffer.ByteBufferIterator iterator = 
buffer.readableByteBuffers()) {
+                    while (iterator.hasNext()) {
+                        final ByteBuffer byteBuffer = 
iterator.next().asReadOnlyBuffer();
+                        final int remainingCapacity = MAX_CAPTURE_BYTES - 
body.size();
+                        if (byteBuffer.remaining() > remainingCapacity) {
+                            captureLimitExceeded = true;
+                            LOG.warn("Response body for session {} exceeds the 
{} byte capture limit",
+                                    sessionId, MAX_CAPTURE_BYTES);
+                            return;
+                        }
+                        final byte[] bytes = new byte[byteBuffer.remaining()];
+                        byteBuffer.get(bytes);
+                        body.write(bytes, 0, bytes.length);
+                    }
+                }
             }
         } finally {
             DataBufferUtils.release(buffer);
@@ -136,17 +175,27 @@ public class NonCommittingMcpResponseDecorator extends 
ServerHttpResponseDecorat
     }
 
     /**
-     * Completes the response future with the accumulated response data after 
all chunks
-     * have been received.
-     *
-     * <p>Applies response template transformations if configured, and 
completes the response
-     * future with the processed result.
+     * Completes the response future with the accumulated response data after 
all chunks have
+     * been received. Decodes the accumulated bytes exactly once, applies the 
response template
+     * and completes the response future. Completing is idempotent: if the 
future is already
+     * done, this is a no-op. When the capture limit was exceeded the future 
is completed
+     * exceptionally with a deterministic error instead of returning a 
truncated result.
      */
-    private void completeResponseFuture() {
-        final String responseBody;
+    private void finishResponse() {
+        if (responseFuture.isDone()) {
+            return;
+        }
+        if (captureLimitExceeded) {
+            responseFuture.completeExceptionally(
+                    new IllegalStateException("MCP response exceeds the 1 MiB 
capture limit"));
+            return;
+        }
+        final byte[] responseBytes;
         synchronized (body) {
-            responseBody = body.toString();
+            responseBytes = body.toByteArray();
         }
+        // Single decode across the whole payload preserves UTF-8 sequences 
split across chunks.
+        final String responseBody = new String(responseBytes, 
StandardCharsets.UTF_8);
         LOG.debug("Captured response data for session {}, length: {} chars", 
sessionId, responseBody.length());
 
         try {
diff --git 
a/shenyu-plugin/shenyu-plugin-mcp-server/src/test/java/org/apache/shenyu/plugin/mcp/server/response/NonCommittingMcpResponseDecoratorTest.java
 
b/shenyu-plugin/shenyu-plugin-mcp-server/src/test/java/org/apache/shenyu/plugin/mcp/server/response/NonCommittingMcpResponseDecoratorTest.java
index 3287a7d49e..57571d0cc5 100644
--- 
a/shenyu-plugin/shenyu-plugin-mcp-server/src/test/java/org/apache/shenyu/plugin/mcp/server/response/NonCommittingMcpResponseDecoratorTest.java
+++ 
b/shenyu-plugin/shenyu-plugin-mcp-server/src/test/java/org/apache/shenyu/plugin/mcp/server/response/NonCommittingMcpResponseDecoratorTest.java
@@ -173,6 +173,44 @@ public final class NonCommittingMcpResponseDecoratorTest {
         assertEquals(0, buffer.getNativeBuffer().refCnt());
     }
 
+    // ---------------------------------------------------------------
+    // Bounded capture: UTF-8 boundaries and over-limit behaviour
+    // ---------------------------------------------------------------
+
+    @Test
+    void testWriteWithPreservesUtf8AcrossChunkBoundaries() throws Exception {
+        final CompletableFuture<String> future = new CompletableFuture<>();
+        final NonCommittingMcpResponseDecorator decorator =
+                new NonCommittingMcpResponseDecorator(delegate, "session-1", 
future, null);
+
+        // "a€b" => 0x61 0xE2 0x82 0xAC 0x62; split inside the 3-byte euro 
sign.
+        final byte[] full = "a€b".getBytes(StandardCharsets.UTF_8);
+        final DataBuffer first = bufferFactory.wrap(new byte[]{full[0], 
full[1]});
+        final DataBuffer second = bufferFactory.wrap(new byte[]{full[2], 
full[3], full[4]});
+
+        decorator.writeWith(Flux.just(first, second)).block();
+
+        assertEquals("a€b", future.get(5, TimeUnit.SECONDS));
+    }
+
+    @Test
+    void testWriteWithExceedingCaptureLimitFailsDeterministically() {
+        final int overLimitBytes = 1024 * 1024 + 1;
+        final NettyDataBufferFactory factory = new 
NettyDataBufferFactory(PooledByteBufAllocator.DEFAULT);
+        final NettyDataBuffer buffer = factory.allocateBuffer(overLimitBytes);
+        buffer.write(new byte[overLimitBytes]);
+
+        final CompletableFuture<String> future = new CompletableFuture<>();
+        final NonCommittingMcpResponseDecorator decorator = new 
NonCommittingMcpResponseDecorator(
+                new MockServerHttpResponse(), "session", future, null);
+
+        decorator.writeWith(Mono.just(buffer)).block();
+
+        assertTrue(future.isCompletedExceptionally());
+        // The over-limit buffer must still be released.
+        assertEquals(0, buffer.getNativeBuffer().refCnt());
+    }
+
     private DataBuffer buffer(final String content) {
         return bufferFactory.wrap(content.getBytes(StandardCharsets.UTF_8));
     }

Reply via email to