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 9350d318a4 feat(agent-gateway): filter MCP result fields and verified 
JSON copies (#7549)
9350d318a4 is described below

commit 9350d318a422205f0a2c1ae0e585f1c7439cd161
Author: BobSong <[email protected]>
AuthorDate: Mon Oct 12 09:48:23 2026 +0800

    feat(agent-gateway): filter MCP result fields and verified JSON copies 
(#7549)
    
    Co-authored-by: BobSong-dev <[email protected]>
---
 RELEASE-NOTES.md                                   |  10 +
 .../AgentGatewayRuleHandleValidatorTest.java       |  14 +
 .../shenyu/common/dto/AgentGatewayMcpConfig.java   |  10 +-
 .../common/dto/AgentGatewayResultFilterConfig.java | 146 +++++++
 .../dto/AgentGatewayResultFilterConfigTest.java    |  89 +++++
 .../shenyu-plugin-agent-gateway/README.md          |  80 ++++
 shenyu-plugin/shenyu-plugin-agent-gateway/pom.xml  |   9 +
 .../agent/gateway/protocol/AgentMcpDispatcher.java |  42 ++-
 .../gateway/protocol/AgentMcpExecutionContext.java |  15 +
 .../gateway/protocol/AgentMcpHttpHandler.java      |   2 +-
 .../protocol/AgentMcpOutputSchemaValidator.java    | 155 ++++++++
 .../protocol/AgentMcpResultCopiesFilter.java       | 114 ++++++
 .../gateway/protocol/AgentMcpResultFilter.java     |  60 +++
 .../protocol/AgentMcpStructuredContentFilter.java  | 136 +++++++
 .../AgentMcpOutputSchemaValidatorTest.java         | 178 +++++++++
 .../protocol/AgentMcpResultCopiesFilterTest.java   | 150 ++++++++
 .../protocol/AgentMcpResultFilteringTest.java      | 418 +++++++++++++++++++++
 .../AgentMcpStructuredContentFilterTest.java       | 259 +++++++++++++
 18 files changed, 1876 insertions(+), 11 deletions(-)

diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md
index 196d32e489..acb4c64256 100644
--- a/RELEASE-NOTES.md
+++ b/RELEASE-NOTES.md
@@ -6,6 +6,16 @@
 
 ### Behavior Changes
 
+- Agent Gateway supports opt-in per-tool `mcp.structuredContentRemovals` field
+  deletion with strict result-copy handling. Complete JSON text copies are 
rebuilt
+  from filtered structuredContent. Opaque text/media, nonempty metadata and 
unknown
+  extensions are refused when a plan is active; disabled plans retain prior 
behavior.
+  This is declared-field filtering, not arbitrary sensitive-value detection or
+  global log redaction. Advertised outputSchema
+  remains unchanged and filtered results must satisfy the supported bounded
+  Draft 2020-12 contract. Filtering/schema failures return HTTP 502 / JSON-RPC
+  `-32024` without an unfiltered fallback; external schema resource loading is 
denied.
+
 - Agent Gateway remote tools aggregation is opt-in. Its plugin configuration 
now
   accepts only the `aggregation` field; blank config and `{}` remain valid.
   Previously stored arbitrary fields must be removed or migrated before the 
next
diff --git 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/validation/validator/AgentGatewayRuleHandleValidatorTest.java
 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/validation/validator/AgentGatewayRuleHandleValidatorTest.java
index 6ce679f106..9add7821b5 100644
--- 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/validation/validator/AgentGatewayRuleHandleValidatorTest.java
+++ 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/validation/validator/AgentGatewayRuleHandleValidatorTest.java
@@ -65,4 +65,18 @@ public final class AgentGatewayRuleHandleValidatorTest {
     public void testNullHandle() {
         assertThrows(ShenyuAdminException.class, () -> 
AgentGatewayRuleHandleValidator.validate(null));
     }
+
+    @Test
+    public void testValidStructuredContentDeletion() {
+        assertDoesNotThrow(() -> 
AgentGatewayRuleHandleValidator.validate("{\"trafficType\":\"mcp\",\"mcp\":{\"allowedTools\":[\"crm.profile\"],"
+                + 
"\"structuredContentRemovals\":{\"crm.profile\":[\"/secret\"]}}}"));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"null", "[]", "{\"crm.profile\":[]}", 
"{\"other.profile\":[\"/secret\"]}",
+            "{\"crm.profile\":[\"secret\"]}", "{\"crm.profile\":[\"/a~2\"]}", 
"{\"crm.profile\":[\"/secret\",\"/secret\"]}"})
+    public void testInvalidStructuredContentDeletion(final String deletion) {
+        String handle = 
"{\"trafficType\":\"mcp\",\"mcp\":{\"allowedTools\":[\"crm.profile\"],\"structuredContentRemovals\":"
 + deletion + "}}";
+        assertThrows(ShenyuAdminException.class, () -> 
AgentGatewayRuleHandleValidator.validate(handle));
+    }
 }
diff --git 
a/shenyu-common/src/main/java/org/apache/shenyu/common/dto/AgentGatewayMcpConfig.java
 
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/AgentGatewayMcpConfig.java
index 9f721b04c7..ecc2067311 100644
--- 
a/shenyu-common/src/main/java/org/apache/shenyu/common/dto/AgentGatewayMcpConfig.java
+++ 
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/AgentGatewayMcpConfig.java
@@ -31,7 +31,8 @@ import java.util.Set;
  */
 public final class AgentGatewayMcpConfig {
 
-    private static final Set<String> FIELDS = Set.of("allowedTools", 
"allowedOrigins", "responseMode", "timeoutMs", "maxRequestBytes", 
"maxResponseBytes");
+    private static final Set<String> FIELDS = Set.of("allowedTools", 
"allowedOrigins", "responseMode", "timeoutMs", "maxRequestBytes", 
"maxResponseBytes",
+            "structuredContentRemovals");
 
     private final Set<String> allowedTools;
 
@@ -45,6 +46,8 @@ public final class AgentGatewayMcpConfig {
 
     private final int maxResponseBytes;
 
+    private final AgentGatewayResultFilterConfig resultFilters;
+
     private AgentGatewayMcpConfig(final JsonObject object) {
         allowedTools = strings(object, "allowedTools", false);
         allowedOrigins = strings(object, "allowedOrigins", true);
@@ -55,6 +58,7 @@ public final class AgentGatewayMcpConfig {
         timeoutMs = integer(object, "timeoutMs", 30000, 100, 120000);
         maxRequestBytes = integer(object, "maxRequestBytes", 262144, 1024, 
1048576);
         maxResponseBytes = integer(object, "maxResponseBytes", 1048576, 1024, 
4194304);
+        resultFilters = 
AgentGatewayResultFilterConfig.parse(object.get("structuredContentRemovals"), 
allowedTools);
     }
 
     /**
@@ -103,6 +107,10 @@ public final class AgentGatewayMcpConfig {
         return maxResponseBytes;
     }
 
+    public AgentGatewayResultFilterConfig getResultFilters() {
+        return resultFilters;
+    }
+
     private Set<String> strings(final JsonObject object, final String field, 
final boolean origins) {
         if (!object.has(field)) {
             return Set.of();
diff --git 
a/shenyu-common/src/main/java/org/apache/shenyu/common/dto/AgentGatewayResultFilterConfig.java
 
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/AgentGatewayResultFilterConfig.java
new file mode 100644
index 0000000000..8f564cc210
--- /dev/null
+++ 
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/AgentGatewayResultFilterConfig.java
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.common.dto;
+
+import com.google.gson.JsonElement;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Shared immutable, explicit per-tool structuredContent field-deletion plans.
+ * Paths are relative to structuredContent; no content text or schema is 
rewritten.
+ */
+public final class AgentGatewayResultFilterConfig {
+
+    private final Map<String, List<List<String>>> plans;
+
+    private AgentGatewayResultFilterConfig(final Map<String, 
List<List<String>>> values) {
+        plans = Map.copyOf(values);
+    }
+
+    /**
+     * Return an inactive configuration.
+     * @return empty immutable plans
+     */
+    public static AgentGatewayResultFilterConfig empty() {
+        return new AgentGatewayResultFilterConfig(Map.of());
+    }
+
+    /**
+     * Parse bounded plans consistently for Admin and gateway rule snapshots.
+     * @param element tool-name to path-array object, or absent value
+     * @param allowedTools explicit rule tool allowlist
+     * @return immutable compiled configuration
+     */
+    public static AgentGatewayResultFilterConfig parse(final JsonElement 
element, final Set<String> allowedTools) {
+        Objects.requireNonNull(allowedTools, "allowedTools");
+        if (Objects.isNull(element)) {
+            return empty();
+        }
+        if (!element.isJsonObject() || element.getAsJsonObject().size() > 32) {
+            throw invalid();
+        }
+        Map<String, List<List<String>>> plans = new LinkedHashMap<>();
+        int total = 0;
+        for (Map.Entry<String, JsonElement> entry : 
element.getAsJsonObject().entrySet()) {
+            if (!allowedTools.contains(entry.getKey()) || 
entry.getKey().length() > 128 || !entry.getValue().isJsonArray()
+                    || entry.getValue().getAsJsonArray().isEmpty()) {
+                throw invalid();
+            }
+            List<String> paths = new ArrayList<>();
+            for (JsonElement path : entry.getValue().getAsJsonArray()) {
+                if (!path.isJsonPrimitive() || 
!path.getAsJsonPrimitive().isString() || ++total > 128) {
+                    throw invalid();
+                }
+                paths.add(path.getAsString());
+            }
+            plans.put(entry.getKey(), compilePaths(paths));
+        }
+        return new AgentGatewayResultFilterConfig(plans);
+    }
+
+    /**
+     * Compile one bounded exact field-deletion plan.
+     * @param fieldPaths relative field paths
+     * @return deeply immutable decoded path tokens
+     */
+    public static List<List<String>> compilePaths(final List<String> 
fieldPaths) {
+        if (Objects.isNull(fieldPaths) || fieldPaths.size() > 32) {
+            throw invalid();
+        }
+        Set<String> unique = new HashSet<>();
+        List<List<String>> compiled = new ArrayList<>();
+        for (String path : fieldPaths) {
+            if (Objects.isNull(path) || path.isEmpty() || path.length() > 512 
|| !path.startsWith("/") || !unique.add(path)) {
+                throw invalid();
+            }
+            String[] segments = path.substring(1).split("/", -1);
+            if (segments.length > 16) {
+                throw invalid();
+            }
+            List<String> tokens = new ArrayList<>();
+            for (String segment : segments) {
+                tokens.add(decode(segment));
+            }
+            compiled.add(List.copyOf(tokens));
+        }
+        return List.copyOf(compiled);
+    }
+
+    /**
+     * Get the immutable plan for one complete exposed tool name.
+     * @param toolName complete exposed name, not a split server or suffix
+     * @return decoded field paths, or an empty inactive plan
+     */
+    public List<List<String>> getPlan(final String toolName) {
+        return plans.getOrDefault(toolName, List.of());
+    }
+
+    private static String decode(final String token) {
+        final StringBuilder decoded = new StringBuilder();
+        boolean escaped = false;
+        for (int index = 0; index < token.length(); index++) {
+            char character = token.charAt(index);
+            if (escaped) {
+                if (character != '0' && character != '1') {
+                    throw invalid();
+                }
+                decoded.append(character == '0' ? '~' : '/');
+                escaped = false;
+            } else if (character == '~') {
+                escaped = true;
+            } else {
+                decoded.append(character);
+            }
+        }
+        if (escaped) {
+            throw invalid();
+        }
+        return decoded.toString();
+    }
+
+    private static IllegalArgumentException invalid() {
+        return new IllegalArgumentException("mcp.structuredContentRemovals 
must contain bounded explicit field-deletion plans for allowed tools");
+    }
+}
diff --git 
a/shenyu-common/src/test/java/org/apache/shenyu/common/dto/AgentGatewayResultFilterConfigTest.java
 
b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/AgentGatewayResultFilterConfigTest.java
new file mode 100644
index 0000000000..91ba8c2c6f
--- /dev/null
+++ 
b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/AgentGatewayResultFilterConfigTest.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.common.dto;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class AgentGatewayResultFilterConfigTest {
+
+    @Test
+    void acceptsAbsentAndEmptyPolicies() {
+        assertTrue(AgentGatewayMcpConfig.parse(new JsonObject(), 
true).getResultFilters().getPlan("crm.profile").isEmpty());
+        
assertTrue(config("{\"allowedTools\":[\"crm.profile\"],\"structuredContentRemovals\":{}}",
 true)
+                .getResultFilters().getPlan("crm.profile").isEmpty());
+    }
+
+    @Test
+    void capturesDecodedImmutableExactToolPlans() {
+        JsonObject input = 
JsonParser.parseString("{\"allowedTools\":[\"crm.profile\"],"
+                + 
"\"structuredContentRemovals\":{\"crm.profile\":[\"/a~1b/~0\",\"/customer/secret\"]}}").getAsJsonObject();
+        AgentGatewayMcpConfig snapshot = AgentGatewayMcpConfig.parse(input, 
true);
+        
input.getAsJsonObject("structuredContentRemovals").remove("crm.profile");
+        assertEquals(List.of(List.of("a/b", "~"), List.of("customer", 
"secret")), snapshot.getResultFilters().getPlan("crm.profile"));
+        assertTrue(snapshot.getResultFilters().getPlan("profile").isEmpty());
+        assertThrows(UnsupportedOperationException.class, () -> 
snapshot.getResultFilters().getPlan("crm.profile").clear());
+        assertThrows(UnsupportedOperationException.class, () -> 
snapshot.getResultFilters().getPlan("crm.profile").get(0).clear());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"null", "[]", "true", 
"{\"other.profile\":[\"/secret\"]}", "{\"profile\":[\"/secret\"]}",
+            "{\"crm.profile\":null}", "{\"crm.profile\":[]}", 
"{\"crm.profile\":true}", "{\"crm.profile\":[null]}",
+            "{\"crm.profile\":[1]}", "{\"crm.profile\":[\"\"]}", 
"{\"crm.profile\":[\"secret\"]}",
+            "{\"crm.profile\":[\"/a~2\"]}", 
"{\"crm.profile\":[\"/secret\",\"/secret\"]}"})
+    void rejectsInvalidPlansEquallyAtAdminAndGateway(final String value) {
+        String input = 
"{\"allowedTools\":[\"crm.profile\"],\"structuredContentRemovals\":" + value + 
"}";
+        assertThrows(IllegalArgumentException.class, () -> config(input, 
true));
+        assertThrows(IllegalArgumentException.class, () -> config(input, 
false));
+    }
+
+    @Test
+    void enforcesPerToolAndTotalPathLimits() {
+        JsonObject input = new JsonObject();
+        input.add("allowedTools", new com.google.gson.JsonArray());
+        JsonObject policy = new JsonObject();
+        input.add("structuredContentRemovals", policy);
+        for (int tool = 0; tool < 5; tool++) {
+            String name = "server.tool" + tool;
+            input.getAsJsonArray("allowedTools").add(name);
+            var paths = new com.google.gson.JsonArray();
+            for (int path = 0; path < 32; path++) {
+                paths.add("/field" + path);
+            }
+            policy.add(name, paths);
+        }
+        assertThrows(IllegalArgumentException.class, () -> 
AgentGatewayMcpConfig.parse(input, true));
+        policy.remove("server.tool4");
+        AgentGatewayMcpConfig.parse(input, true);
+        policy.getAsJsonArray("server.tool0").add("/extra");
+        assertThrows(IllegalArgumentException.class, () -> 
AgentGatewayMcpConfig.parse(input, true));
+    }
+
+    private AgentGatewayMcpConfig config(final String input, final boolean 
strict) {
+        return AgentGatewayMcpConfig.parse(JsonParser.parseString(input), 
strict);
+    }
+}
diff --git a/shenyu-plugin/shenyu-plugin-agent-gateway/README.md 
b/shenyu-plugin/shenyu-plugin-agent-gateway/README.md
index 1563b27a4a..f49bc9222f 100644
--- a/shenyu-plugin/shenyu-plugin-agent-gateway/README.md
+++ b/shenyu-plugin/shenyu-plugin-agent-gateway/README.md
@@ -428,3 +428,83 @@ per Server, 16 remote tools, 8 KiB request/response, 64 
in-flight requests per c
 5-second directory update and 2-second session DELETE completion. The trusted
 request deadline bounds the individual HTTP future; cancellation does not 
promise
 remote business rollback, termination of arbitrary backend code or stopped 
billing.
+
+## Optional structuredContent field deletion
+
+Configure explicit plans in the matched MCP rule, keyed by complete exposed 
tool
+name. The default is absent/empty, preserving existing results without schema 
work.
+
+```json
+{
+  "trafficType": "mcp",
+  "mcp": {
+    "allowedTools": ["crm.profile"],
+    "structuredContentRemovals": {
+      "crm.profile": ["/internalNote", "/customers/0/privateNote"]
+    }
+  }
+}
+```
+
+Admin and gateway share the immutable validator. Only rule-allowed exact tool
+names may have plans: no suffix/prefix matching or wildcard expansion. Paths 
are
+relative to structuredContent, use `~0`/`~1` for literal tilde/slash, and end 
at
+object fields. Root and array-element deletion are forbidden; array indexes may
+reach object fields but do not shift elements. Missing optional fields are 
no-ops;
+incompatible traversal fails. Limits: 32 tools, 32 paths/tool, 128 paths/rule,
+512 characters/path, 16 path segments. Plans are captured with the request's
+rule snapshot; an update does not change a call already executing.
+
+Authorization precedes filtering preparation and provider execution. Results 
are
+filtered after the tool completes and before JSON/SSE encoding and 
response-size
+checks. Active filtering validates/copies at most 32 levels, 16,384 nodes and
+2,097,152 aggregate Java character units. Failures do not retry the tool or 
return
+the original/partially filtered result. The response is HTTP 502 / JSON-RPC
+`-32024`, `Tool result filtering failed`, using JSON even for an 
SSE-configured rule.
+
+Advertised remote outputSchema is kept unchanged in tools/list. When a plan is
+active, compile that snapshot before invoking the target, then validate 
filtered
+structuredContent against it. Active filtering requires a structuredContent 
object
+for business errors too, and requires every result text block to be its 
complete
+JSON copy. A server business error without a verifiable object is refused.
+Local providers currently advertise no outputSchema; their results still use
+the same configured deletion path.
+
+Schema validation is intentionally bounded, not unrestricted JSON Schema 
support:
+reference-free Draft 2020-12, absent or canonical `$schema`, up to 512 schema 
nodes,
+16 levels and 65,536 aggregate characters; individual strings at most 4,096 and
+arrays at most 128. Instance validation is limited to 2,048 nodes with the same
+depth/string/character/array bounds. Composition nesting is at most two and 
each
+allOf/anyOf/oneOf/prefixItems array at most four. References, regex pattern 
keywords,
+unknown assertions/dialects and excessive complexity are explicitly refused;
+standard Draft 2020-12 format remains an annotation, not an added assertion.
+Only bundled standard meta-schema resources may load: no external HTTP, file or
+arbitrary classpath resource fetching. This does not rewrite or weaken the 
schema.
+
+Active plans use a strict result-copy contract, not best-effort text masking:
+
+- structuredContent must be an object, and content must be an array of at most
+  32 blocks (an empty array is permitted);
+- every block must contain only type=text and a text string whose strict JSON
+  object is equal to the original complete structuredContent. Duplicate keys,
+  trailing documents, malformed/deep or excessive-token JSON are refused;
+- each accepted text copy is rebuilt from filtered structuredContent, so 
removed
+  fields cannot remain in those JSON copies. Free text, partial/different JSON,
+  images/audio/resources, annotations and block metadata are refused;
+- result metadata must be absent or an empty object. Unknown result extensions
+  are refused. Optional isError must be boolean; optional resultType must be 
the
+  fixed complete marker. Untrusted result rejection returns the same safe
+  502/-32024 without tool retry or raw-result fallback;
+- local calls under active plans omit generated serverInfo metadata. Known 
local
+  validation/execution failures use fixed structured error messages and 
matching
+  JSON text copies; provider exception details are not returned;
+- disabled plans and tools without a configured plan keep existing behavior.
+
+This protects declared object fields and their verified complete result copies.
+**It is not automatic identification of every sensitive value**: values 
repeated
+under other unconfigured structured fields, encoded/encrypted values, requests,
+discovery/list descriptions and operator/upstream logs are outside this policy.
+It is not arbitrary free-text redaction or prompt-injection prevention. 
Operators
+must enumerate the fields to remove and use compatible tools; unsupported 
results
+fail closed rather than being described as safely redacted. The original schema
+is never rewritten or weakened to make a deletion pass.
diff --git a/shenyu-plugin/shenyu-plugin-agent-gateway/pom.xml 
b/shenyu-plugin/shenyu-plugin-agent-gateway/pom.xml
index 47c89b1117..b275174cf8 100644
--- a/shenyu-plugin/shenyu-plugin-agent-gateway/pom.xml
+++ b/shenyu-plugin/shenyu-plugin-agent-gateway/pom.xml
@@ -25,6 +25,10 @@
     <modelVersion>4.0.0</modelVersion>
     <artifactId>shenyu-plugin-agent-gateway</artifactId>
 
+    <properties>
+        <json-schema-validator.version>2.0.0</json-schema-validator.version>
+    </properties>
+
     <build>
         <resources>
             <resource>
@@ -38,6 +42,11 @@
     </build>
 
     <dependencies>
+        <dependency>
+            <groupId>com.networknt</groupId>
+            <artifactId>json-schema-validator</artifactId>
+            <version>${json-schema-validator.version}</version>
+        </dependency>
         <dependency>
             <groupId>io.modelcontextprotocol.sdk</groupId>
             <artifactId>mcp-core</artifactId>
diff --git 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpDispatcher.java
 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpDispatcher.java
index 751d18e21d..20c4b63a68 100644
--- 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpDispatcher.java
+++ 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpDispatcher.java
@@ -139,6 +139,7 @@ public final class AgentMcpDispatcher {
                 return Mono.error(failure(request, 403, -32602, "Tool is not 
available"));
             }
             if (definitions.containsKey(name)) {
+                final AgentMcpResultFilter filter = 
prepareResultFilter(request, context, name, 
definitions.get(name).get("outputSchema"));
                 ObjectNode params = request.getParams();
                 JsonObject arguments = params.has("arguments") ? 
JsonParser.parseString(params.get("arguments").toString()).getAsJsonObject() : 
new JsonObject();
                 return snapshot.invoke(name, arguments, context)
@@ -146,7 +147,7 @@ public final class AgentMcpDispatcher {
                         .map(value -> {
                             ObjectNode result = value.deepCopy();
                             result.put("resultType", "complete");
-                            return response(request, result);
+                            return response(request, filterResult(request, 
filter, result));
                         })
                         .onErrorMap(SecurityException.class, error -> 
failure(request, 403, -32602, "Tool is not available"));
             }
@@ -192,20 +193,22 @@ public final class AgentMcpDispatcher {
         if (Objects.isNull(definition)) {
             return Mono.error(failure(request, 403, -32602, "Tool is not 
available"));
         }
+        final AgentMcpResultFilter filter = prepareResultFilter(request, 
context, name, null);
         JsonObject arguments = params.has("arguments") ? 
JsonParser.parseString(params.get("arguments").toString()).getAsJsonObject() : 
new JsonObject();
         JsonObject capabilities = request.getClientCapabilities();
         return registry.invoke(name, context.getAllowedTools(), () -> new 
AgentToolInvocation(context.getRequestId(), context.getSubject(), arguments,
                         context.getRuleId(), 
context.getConfigurationVersion(), context.getDeadline(), capabilities))
                 .map(value -> {
-                    ObjectNode result = complete();
+                    // Active field policies must not introduce an opaque 
metadata channel.
+                    ObjectNode result = filter.isActive() ? 
mapper.createObjectNode().put("resultType", "complete") : complete();
                     JsonNode content = toTree(value);
                     result.set("structuredContent", content);
                     result.putArray("content").addObject().put("type", 
"text").put("text", content.toString());
                     result.put("isError", false);
-                    return response(request, result);
+                    return response(request, filterResult(request, filter, 
result));
                 })
-                .onErrorResume(AgentToolArgumentException.class, error -> 
Mono.just(toolFailure(request, "Invalid tool arguments")))
-                .onErrorResume(AgentToolExecutionException.class, error -> 
Mono.just(toolFailure(request, "Tool execution failed")))
+                .onErrorResume(AgentToolArgumentException.class, error -> 
Mono.just(toolFailure(request, "Invalid tool arguments", filter)))
+                .onErrorResume(AgentToolExecutionException.class, error -> 
Mono.just(toolFailure(request, "Tool execution failed", filter)))
                 .onErrorMap(AgentToolCapabilityException.class, error -> {
                     ObjectNode data = mapper.createObjectNode();
                     data.set("requiredCapabilities", 
toTree(error.getRequiredCapabilities()));
@@ -214,11 +217,32 @@ public final class AgentMcpDispatcher {
                 .onErrorMap(SecurityException.class, error -> failure(request, 
403, -32602, "Tool is not available"));
     }
 
-    private ObjectNode toolFailure(final AgentMcpRequest request, final String 
message) {
-        ObjectNode result = complete();
-        result.putArray("content").addObject().put("type", "text").put("text", 
message);
+    private ObjectNode toolFailure(final AgentMcpRequest request, final String 
message, final AgentMcpResultFilter filter) {
+        ObjectNode result = filter.isActive() ? 
mapper.createObjectNode().put("resultType", "complete") : complete();
+        if (filter.isActive()) {
+            result.putObject("structuredContent").put("error", message);
+        }
+        String text = filter.isActive() ? 
result.get("structuredContent").toString() : message;
+        result.putArray("content").addObject().put("type", "text").put("text", 
text);
         result.put("isError", true);
-        return response(request, result);
+        return response(request, filterResult(request, filter, result));
+    }
+
+    private AgentMcpResultFilter prepareResultFilter(final AgentMcpRequest 
request, final AgentMcpExecutionContext context,
+                                                    final String name, final 
JsonNode schema) {
+        try {
+            return new AgentMcpResultFilter(context.getResultFilters(), name, 
schema);
+        } catch (RuntimeException error) {
+            throw failure(request, 502, -32024, "Tool result filtering 
failed");
+        }
+    }
+
+    private ObjectNode filterResult(final AgentMcpRequest request, final 
AgentMcpResultFilter filter, final ObjectNode result) {
+        try {
+            return filter.apply(result);
+        } catch (RuntimeException error) {
+            throw failure(request, 502, -32024, "Tool result filtering 
failed");
+        }
     }
 
     private ObjectNode complete() {
diff --git 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpExecutionContext.java
 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpExecutionContext.java
index 1717a09b7f..7a4de4de4f 100644
--- 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpExecutionContext.java
+++ 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpExecutionContext.java
@@ -17,6 +17,8 @@
 
 package org.apache.shenyu.plugin.agent.gateway.protocol;
 
+import org.apache.shenyu.common.dto.AgentGatewayResultFilterConfig;
+
 import java.util.HashSet;
 import java.util.Objects;
 import java.util.Set;
@@ -42,6 +44,8 @@ public final class AgentMcpExecutionContext {
 
     private final Instant deadline;
 
+    private final AgentGatewayResultFilterConfig resultFilters;
+
     public AgentMcpExecutionContext(final String requestId, final String 
subject, final String ruleId, final long configurationVersion,
                                     final Set<String> ruleTools, final 
Set<String> securityGrants) {
         this(requestId, subject, ruleId, configurationVersion, ruleTools, 
securityGrants, Instant.MAX);
@@ -49,7 +53,14 @@ public final class AgentMcpExecutionContext {
 
     public AgentMcpExecutionContext(final String requestId, final String 
subject, final String ruleId, final long configurationVersion,
                                     final Set<String> ruleTools, final 
Set<String> securityGrants, final Instant deadline) {
+        this(requestId, subject, ruleId, configurationVersion, ruleTools, 
securityGrants, deadline, AgentGatewayResultFilterConfig.empty());
+    }
+
+    public AgentMcpExecutionContext(final String requestId, final String 
subject, final String ruleId, final long configurationVersion,
+                                    final Set<String> ruleTools, final 
Set<String> securityGrants, final Instant deadline,
+                                    final AgentGatewayResultFilterConfig 
resultFilters) {
         this.deadline = Objects.requireNonNull(deadline, "deadline");
+        this.resultFilters = Objects.requireNonNull(resultFilters, 
"resultFilters");
         this.requestId = requireText(requestId, "requestId");
         this.subject = requireText(subject, "subject");
         this.ruleId = requireText(ruleId, "ruleId");
@@ -91,6 +102,10 @@ public final class AgentMcpExecutionContext {
         return allowedTools;
     }
 
+    public AgentGatewayResultFilterConfig getResultFilters() {
+        return resultFilters;
+    }
+
     private String requireText(final String value, final String name) {
         if (Objects.requireNonNull(value, name).isBlank()) {
             throw new IllegalArgumentException(name + " must not be blank");
diff --git 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpHttpHandler.java
 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpHttpHandler.java
index 354d1765de..aa95fe9b95 100644
--- 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpHttpHandler.java
+++ 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpHttpHandler.java
@@ -92,7 +92,7 @@ public final class AgentMcpHttpHandler {
                                 rpcId.set(request.getId());
                                 observation.parsed(request);
                                 return dispatcher.dispatch(request, () -> new 
AgentMcpExecutionContext(traffic.getRequestId(), identity.getSubject(),
-                                        traffic.getRuleId(), 
configurationVersion, config.getAllowedTools(), identity.getToolGrants(), 
deadline));
+                                        traffic.getRuleId(), 
configurationVersion, config.getAllowedTools(), identity.getToolGrants(), 
deadline, config.getResultFilters()));
                             }))
                     .flatMap(response -> {
                         observation.response(response);
diff --git 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpOutputSchemaValidator.java
 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpOutputSchemaValidator.java
new file mode 100644
index 0000000000..4a5f5cee5f
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpOutputSchemaValidator.java
@@ -0,0 +1,155 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.agent.gateway.protocol;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.networknt.schema.OutputFormat;
+import com.networknt.schema.Schema;
+import com.networknt.schema.SchemaLocation;
+import com.networknt.schema.SchemaRegistry;
+import com.networknt.schema.SchemaRegistryConfig;
+import com.networknt.schema.SpecificationVersion;
+import com.networknt.schema.resource.SchemaLoader;
+
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Request-owned validation against an unchanged, bounded Draft 2020-12 schema.
+ * Unsupported or expensive schemas fail closed; no file or network resources 
load.
+ */
+final class AgentMcpOutputSchemaValidator {
+
+    private static final String DIALECT = 
"https://json-schema.org/draft/2020-12/schema";;
+
+    private static final Set<String> META_SCHEMAS = Set.of(DIALECT,
+            "https://json-schema.org/draft/2020-12/meta/core";, 
"https://json-schema.org/draft/2020-12/meta/applicator";,
+            "https://json-schema.org/draft/2020-12/meta/unevaluated";, 
"https://json-schema.org/draft/2020-12/meta/validation";,
+            "https://json-schema.org/draft/2020-12/meta/meta-data";, 
"https://json-schema.org/draft/2020-12/meta/format-annotation";,
+            "https://json-schema.org/draft/2020-12/meta/content";);
+
+    private static final Set<String> KEYWORDS = Set.of("$schema", "$id", 
"$comment", "$defs", "type", "properties", "required",
+            "additionalProperties", "unevaluatedProperties", "items", 
"prefixItems", "unevaluatedItems", "contains", "minContains",
+            "maxContains", "minItems", "maxItems", "uniqueItems", "minLength", 
"maxLength", "minimum", "maximum",
+            "exclusiveMinimum", "exclusiveMaximum", "multipleOf", "enum", 
"const", "allOf", "anyOf", "oneOf", "not",
+            "if", "then", "else", "dependentRequired", "dependentSchemas", 
"propertyNames", "minProperties", "maxProperties",
+            "title", "description", "default", "examples", "deprecated", 
"readOnly", "writeOnly", "format",
+            "contentEncoding", "contentMediaType", "contentSchema");
+
+    private static final Set<String> MAPS = Set.of("properties", "$defs", 
"dependentSchemas");
+
+    private static final Set<String> CHILDREN = Set.of("additionalProperties", 
"unevaluatedProperties", "items", "unevaluatedItems",
+            "contains", "propertyNames", "not", "if", "then", "else", 
"contentSchema");
+
+    private static final Set<String> ARRAYS = Set.of("allOf", "anyOf", 
"oneOf", "prefixItems");
+
+    private final Schema schema;
+
+    AgentMcpOutputSchemaValidator(final JsonNode outputSchema) {
+        if (Objects.isNull(outputSchema) || !outputSchema.isObject()) {
+            throw invalid();
+        }
+        budget(outputSchema, 0, new AtomicInteger(), new AtomicLong(), 512, 
65536);
+        supported(outputSchema, 0);
+        JsonNode owned = outputSchema.deepCopy();
+        SchemaRegistry registry = localRegistry();
+        if (!registry.getSchema(SchemaLocation.of(DIALECT)).validate(owned, 
OutputFormat.BOOLEAN)) {
+            throw invalid();
+        }
+        schema = registry.getSchema(owned);
+    }
+
+    static SchemaRegistry localRegistry() {
+        return 
SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_2020_12, builder 
-> builder
+                .schemaCacheEnabled(false)
+                
.schemaLoader(SchemaLoader.builder().fetchRemoteResources(false)
+                        .allow(iri -> 
META_SCHEMAS.contains(iri.toString())).build())
+                
.schemaRegistryConfig(SchemaRegistryConfig.builder().failFast(true).formatAssertionsEnabled(false).build()));
+    }
+
+    void validate(final JsonNode structuredContent) {
+        if (Objects.isNull(structuredContent) || 
!structuredContent.isObject()) {
+            throw invalid();
+        }
+        budget(structuredContent, 0, new AtomicInteger(), new AtomicLong(), 
2048, 65536);
+        JsonNode validationCopy = structuredContent.deepCopy();
+        if (!schema.validate(validationCopy, OutputFormat.BOOLEAN) || 
!validationCopy.equals(structuredContent)) {
+            throw invalid();
+        }
+    }
+
+    private void supported(final JsonNode node, final int compositionDepth) {
+        if (node.isBoolean()) {
+            return;
+        }
+        if (!node.isObject()) {
+            throw invalid();
+        }
+        node.fields().forEachRemaining(entry -> {
+            String key = entry.getKey();
+            JsonNode value = entry.getValue();
+            if (!KEYWORDS.contains(key) || ("$schema".equals(key) && 
(!value.isTextual() || !DIALECT.equals(value.textValue())))) {
+                throw invalid();
+            }
+            if (MAPS.contains(key)) {
+                if (!value.isObject()) {
+                    throw invalid();
+                }
+                value.elements().forEachRemaining(child -> supported(child, 
compositionDepth));
+            } else if (CHILDREN.contains(key)) {
+                supported(value, compositionDepth + ("not".equals(key) || 
"if".equals(key) ? 1 : 0));
+            } else if (ARRAYS.contains(key)) {
+                if (!value.isArray() || value.size() > 4 || compositionDepth 
>= 2) {
+                    throw invalid();
+                }
+                value.elements().forEachRemaining(child -> supported(child, 
compositionDepth + 1));
+            }
+        });
+        if (compositionDepth > 2) {
+            throw invalid();
+        }
+    }
+
+    private void budget(final JsonNode node, final int depth, final 
AtomicInteger nodes, final AtomicLong characters,
+                        final int maximumNodes, final long maximumCharacters) {
+        if (depth > 16 || nodes.incrementAndGet() > maximumNodes || 
node.isPojo() || node.isBinary() || node.isMissingNode()) {
+            throw invalid();
+        }
+        if (node.isArray() && node.size() > 128) {
+            throw invalid();
+        }
+        if (node.isObject()) {
+            node.fieldNames().forEachRemaining(name -> {
+                if (characters.addAndGet(name.length()) > maximumCharacters) {
+                    throw invalid();
+                }
+            });
+        } else if (node.isValueNode() && (node.asText().length() > 4096 || 
characters.addAndGet(node.asText().length()) > maximumCharacters)) {
+            throw invalid();
+        }
+        for (JsonNode child : node) {
+            budget(child, depth + 1, nodes, characters, maximumNodes, 
maximumCharacters);
+        }
+    }
+
+    private IllegalArgumentException invalid() {
+        return new IllegalArgumentException("Tool output schema or filtered 
result is unsupported or invalid");
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpResultCopiesFilter.java
 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpResultCopiesFilter.java
new file mode 100644
index 0000000000..735803785c
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpResultCopiesFilter.java
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.agent.gateway.protocol;
+
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.StreamReadConstraints;
+import com.fasterxml.jackson.core.StreamReadFeature;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import java.io.IOException;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Reject opaque result channels; rebuild only verified complete JSON copies.
+ * This is a declared-field policy, not arbitrary sensitive-value detection.
+ */
+final class AgentMcpResultCopiesFilter {
+
+    private static final Set<String> RESULT_FIELDS = 
Set.of("structuredContent", "content", "isError", "_meta", "resultType");
+
+    private static final Set<String> TEXT_FIELDS = Set.of("type", "text");
+
+    private static final ObjectMapper MAPPER = new 
ObjectMapper(JsonFactory.builder()
+            .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION)
+            
.streamReadConstraints(StreamReadConstraints.builder().maxNestingDepth(32).maxStringLength(2097152).build()).build())
+            .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
+
+    void apply(final ObjectNode original, final ObjectNode filtered) {
+        JsonNode structured = original.get("structuredContent");
+        if (Objects.isNull(structured) || !structured.isObject()) {
+            throw invalid();
+        }
+        original.fieldNames().forEachRemaining(name -> {
+            if (!RESULT_FIELDS.contains(name)) {
+                throw invalid();
+            }
+        });
+        if (original.has("_meta") && (!original.get("_meta").isObject() || 
!original.get("_meta").isEmpty())) {
+            throw invalid();
+        }
+        if (original.has("isError") && !original.get("isError").isBoolean()) {
+            throw invalid();
+        }
+        if (original.has("resultType") && 
!original.get("resultType").isTextual()) {
+            throw invalid();
+        }
+        if (original.has("resultType") && 
!"complete".equals(original.get("resultType").textValue())) {
+            throw invalid();
+        }
+        JsonNode content = original.get("content");
+        if (Objects.isNull(content) || !content.isArray() || content.size() > 
32) {
+            throw invalid();
+        }
+        for (int index = 0; index < content.size(); index++) {
+            JsonNode block = content.get(index);
+            if (!block.isObject() || 
!"text".equals(block.path("type").asText()) || !block.path("text").isTextual()) 
{
+                throw invalid();
+            }
+            block.fieldNames().forEachRemaining(name -> {
+                if (!TEXT_FIELDS.contains(name)) {
+                    throw invalid();
+                }
+            });
+            if (!readCopy(block.get("text").textValue()).equals(structured)) {
+                throw invalid();
+            }
+            ((ObjectNode) filtered.get("content").get(index)).put("text", 
filtered.get("structuredContent").toString());
+        }
+    }
+
+    private JsonNode readCopy(final String text) {
+        try (JsonParser parser = MAPPER.createParser(text)) {
+            // Bound streaming tokens before allocating a tree, including 
field names.
+            int tokens = 0;
+            while (Objects.nonNull(parser.nextToken())) {
+                if (++tokens > 49152) {
+                    throw invalid();
+                }
+            }
+            JsonNode copy = MAPPER.readTree(text);
+            if (Objects.isNull(copy) || !copy.isObject()) {
+                throw invalid();
+            }
+            return copy;
+        } catch (IOException error) {
+            // Do not retain parser exceptions containing untrusted text.
+            throw invalid();
+        }
+    }
+
+    private IllegalArgumentException invalid() {
+        return new IllegalArgumentException("Tool result contains unsupported 
or unverifiable copies");
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpResultFilter.java
 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpResultFilter.java
new file mode 100644
index 0000000000..3b254e3c21
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpResultFilter.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.agent.gateway.protocol;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.apache.shenyu.common.dto.AgentGatewayResultFilterConfig;
+
+import java.util.Objects;
+
+/**
+ * One request's configured deletion and unchanged-schema validation.
+ */
+final class AgentMcpResultFilter {
+
+    private final AgentMcpStructuredContentFilter filter;
+
+    private final AgentMcpOutputSchemaValidator validator;
+
+    AgentMcpResultFilter(final AgentGatewayResultFilterConfig config, final 
String name, final JsonNode outputSchema) {
+        if (config.getPlan(name).isEmpty()) {
+            filter = null;
+            validator = null;
+        } else {
+            filter = new AgentMcpStructuredContentFilter(config, name);
+            validator = Objects.isNull(outputSchema) || outputSchema.isNull() 
? null : new AgentMcpOutputSchemaValidator(outputSchema);
+        }
+    }
+
+    ObjectNode apply(final ObjectNode result) {
+        if (Objects.isNull(filter)) {
+            return result;
+        }
+        ObjectNode filtered = filter.filter(result);
+        if (Objects.nonNull(validator) && (filtered.has("structuredContent") 
|| !filtered.path("isError").asBoolean(false))) {
+            validator.validate(filtered.get("structuredContent"));
+        }
+        new AgentMcpResultCopiesFilter().apply(result, filtered);
+        return filtered;
+    }
+
+    boolean isActive() {
+        return Objects.nonNull(filter);
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpStructuredContentFilter.java
 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpStructuredContentFilter.java
new file mode 100644
index 0000000000..2b430f7e10
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpStructuredContentFilter.java
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.agent.gateway.protocol;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.apache.shenyu.common.dto.AgentGatewayResultFilterConfig;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Immutable, bounded deletion plan for fields under structuredContent.
+ * This primitive does not rewrite content text, metadata, or tool schemas.
+ */
+final class AgentMcpStructuredContentFilter {
+
+    private static final int MAX_RESULT_DEPTH = 32;
+
+    private static final int MAX_RESULT_NODES = 16384;
+
+    private static final long MAX_RESULT_CHARACTERS = 2097152;
+
+    private final List<List<String>> paths;
+
+    AgentMcpStructuredContentFilter(final List<String> fieldPaths) {
+        paths = AgentGatewayResultFilterConfig.compilePaths(fieldPaths);
+    }
+
+    AgentMcpStructuredContentFilter(final AgentGatewayResultFilterConfig 
config, final String toolName) {
+        paths = config.getPlan(toolName);
+    }
+
+    /**
+     * Apply the plan to a private result copy; an empty plan leaves its input 
untouched.
+     * Missing fields are no-ops, but incompatible traversal fails without raw 
fallback.
+     *
+     * @param result native MCP tool result, not a JSON-RPC envelope
+     * @return unchanged input for an empty plan, otherwise a request-owned 
result copy
+     */
+    ObjectNode filter(final ObjectNode result) {
+        Objects.requireNonNull(result, "result");
+        if (paths.isEmpty()) {
+            return result;
+        }
+        checkBudget(result, 0, new AtomicInteger(), new AtomicLong());
+        ObjectNode filtered = result.deepCopy();
+        JsonNode structured = filtered.get("structuredContent");
+        if (Objects.isNull(structured)) {
+            return filtered;
+        }
+        if (!structured.isObject()) {
+            throw invalid("structuredContent must be an object");
+        }
+        for (List<String> path : paths) {
+            remove((ObjectNode) structured, path);
+        }
+        return filtered;
+    }
+
+    private void remove(final ObjectNode structured, final List<String> 
tokens) {
+        JsonNode parent = structured;
+        for (int index = 0; index < tokens.size() - 1; index++) {
+            if (parent.isObject()) {
+                parent = parent.get(tokens.get(index));
+            } else if (parent.isArray()) {
+                parent = parent.get(arrayIndex(tokens.get(index)));
+            } else {
+                throw invalid("Field deletion cannot traverse a scalar value");
+            }
+            if (Objects.isNull(parent)) {
+                return;
+            }
+        }
+        if (!parent.isObject()) {
+            throw invalid("Field deletion target must be an object field");
+        }
+        ((ObjectNode) parent).remove(tokens.get(tokens.size() - 1));
+    }
+
+    private int arrayIndex(final String token) {
+        if (token.isEmpty() || token.length() > 10 || (token.length() > 1 && 
token.startsWith("0"))
+                || token.chars().anyMatch(character -> character < '0' || 
character > '9')) {
+            throw invalid("Invalid array traversal index");
+        }
+        try {
+            return Integer.parseInt(token);
+        } catch (NumberFormatException error) {
+            throw invalid("Invalid array traversal index");
+        }
+    }
+
+    private void checkBudget(final JsonNode node, final int depth, final 
AtomicInteger nodes, final AtomicLong characters) {
+        if (depth > MAX_RESULT_DEPTH || nodes.incrementAndGet() > 
MAX_RESULT_NODES) {
+            throw invalid("Tool result exceeds the deletion traversal budget");
+        }
+        if (node.isPojo() || node.isBinary() || node.isMissingNode()) {
+            throw invalid("Tool result contains an unsupported node");
+        }
+        if (node.isObject()) {
+            node.fieldNames().forEachRemaining(name -> 
countCharacters(name.length(), characters));
+        } else if (node.isValueNode()) {
+            countCharacters(node.asText().length(), characters);
+        }
+        for (JsonNode child : node) {
+            checkBudget(child, depth + 1, nodes, characters);
+        }
+    }
+
+    private void countCharacters(final int length, final AtomicLong 
characters) {
+        if (characters.addAndGet(length) > MAX_RESULT_CHARACTERS) {
+            throw invalid("Tool result exceeds the deletion character budget");
+        }
+    }
+
+    private IllegalArgumentException invalid(final String message) {
+        return new IllegalArgumentException(message);
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpOutputSchemaValidatorTest.java
 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpOutputSchemaValidatorTest.java
new file mode 100644
index 0000000000..5efdb343b6
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpOutputSchemaValidatorTest.java
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.agent.gateway.protocol;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.google.gson.JsonParser;
+import com.networknt.schema.InvalidSchemaException;
+import com.networknt.schema.SchemaLocation;
+import com.sun.net.httpserver.HttpServer;
+import org.apache.shenyu.common.dto.AgentGatewayResultFilterConfig;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.net.InetSocketAddress;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class AgentMcpOutputSchemaValidatorTest {
+
+    private final ObjectMapper mapper = new ObjectMapper();
+
+    @Test
+    void validatesUnchangedSchemaAfterDeletingOptionalFields() throws 
Exception {
+        ObjectNode declaration = 
json("{\"type\":\"object\",\"properties\":{\"keep\":{\"type\":\"integer\"},\"secret\":{\"type\":\"string\"}},"
+                + "\"required\":[\"keep\"],\"additionalProperties\":false}");
+        ObjectNode original = declaration.deepCopy();
+        AgentMcpResultFilter filter = new AgentMcpResultFilter(config(), 
"crm.profile", declaration);
+        ObjectNode result = 
json("{\"structuredContent\":{\"keep\":3,\"secret\":\"private\"},\"isError\":false}");
+        ObjectNode filtered = filter.apply(result);
+        assertFalse(filtered.path("structuredContent").has("secret"));
+        assertEquals(3, 
filtered.path("structuredContent").path("keep").asInt());
+        assertEquals(original, declaration);
+        assertTrue(result.path("structuredContent").has("secret"));
+    }
+
+    @Test
+    void removingRequiredFieldFailsWithoutRawFallback() throws Exception {
+        AgentMcpResultFilter filter = new AgentMcpResultFilter(config(), 
"crm.profile",
+                json("{\"type\":\"object\",\"required\":[\"secret\"]}"));
+        ObjectNode result = 
json("{\"structuredContent\":{\"secret\":\"private\"},\"isError\":false}");
+        assertThrows(RuntimeException.class, () -> filter.apply(result));
+        assertTrue(result.path("structuredContent").has("secret"));
+    }
+
+    @Test
+    void defaultsCannotRestoreADeletedFieldOrSatisfyARequiredField() throws 
Exception {
+        ObjectNode schema = 
json("{\"type\":\"object\",\"properties\":{\"secret\":{\"type\":\"string\",\"default\":\"private-default\"}}}");
+        AgentMcpResultFilter optional = new AgentMcpResultFilter(config(), 
"crm.profile", schema);
+        ObjectNode result = 
json("{\"structuredContent\":{\"secret\":\"private\",\"keep\":3}}");
+        
assertFalse(optional.apply(result).path("structuredContent").has("secret"));
+        schema.putArray("required").add("secret");
+        AgentMcpResultFilter required = new AgentMcpResultFilter(config(), 
"crm.profile", schema);
+        assertThrows(RuntimeException.class, () -> required.apply(result));
+    }
+
+    @Test
+    void schemaSnapshotIsIndependentOfMutableDefinition() throws Exception {
+        ObjectNode declaration = 
json("{\"type\":\"object\",\"required\":[\"keep\"]}");
+        AgentMcpOutputSchemaValidator validator = new 
AgentMcpOutputSchemaValidator(declaration);
+        declaration.removeAll();
+        assertThrows(RuntimeException.class, () -> 
validator.validate(json("{\"secret\":\"private\"}")));
+        validator.validate(json("{\"keep\":1}"));
+    }
+
+    @Test
+    void ignoresUnsupportedSchemasWhenNoPlanIsConfigured() throws Exception {
+        ObjectNode source = 
json("{\"structuredContent\":{\"secret\":\"private\"}}");
+        AgentMcpResultFilter filter = new 
AgentMcpResultFilter(AgentGatewayResultFilterConfig.empty(), "crm.profile",
+                json("{\"$ref\":\"https://untrusted.invalid/schema\"}";));
+        assertTrue(source == filter.apply(source));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"{\"$ref\":\"#\"}", 
"{\"$ref\":\"https://untrusted.invalid/schema\"}";,
+            "{\"$dynamicRef\":\"#anchor\"}", "{\"pattern\":\"(a+)+$\"}", 
"{\"patternProperties\":{}}",
+            "{\"$schema\":\"https://untrusted.invalid/meta\"}";, 
"{\"type\":\"invalid-type\"}",
+            "{\"properties\":true}", "{\"allOf\":[true,true,true,true,true]}", 
"{\"unknownAssertion\":true}"})
+    void rejectsUnsupportedOrMalformedSchemas(final String declaration) throws 
Exception {
+        assertThrows(RuntimeException.class, () -> new 
AgentMcpOutputSchemaValidator(json(declaration)));
+    }
+
+    @Test
+    void annotationDataDoesNotBecomeAReference() throws Exception {
+        AgentMcpOutputSchemaValidator validator = new 
AgentMcpOutputSchemaValidator(json(
+                
"{\"type\":\"object\",\"properties\":{\"keep\":{\"const\":{\"$ref\":\"not-a-control-plane\"}}}}"));
+        
validator.validate(json("{\"keep\":{\"$ref\":\"not-a-control-plane\"}}"));
+    }
+
+    @Test
+    void actualReferenceResolverCannotContactALocalHttpEndpoint() throws 
Exception {
+        AtomicInteger requests = new AtomicInteger();
+        HttpServer server = HttpServer.create(new 
InetSocketAddress("127.0.0.1", 0), 0);
+        server.createContext("/schema", exchange -> {
+            requests.incrementAndGet();
+            exchange.sendResponseHeaders(500, -1);
+            exchange.close();
+        });
+        server.start();
+        try {
+            String uri = "http://127.0.0.1:"; + server.getAddress().getPort() + 
"/schema";
+            JsonNode declaration = json("{\"$ref\":\"" + uri + "\"}");
+            InvalidSchemaException failure = 
assertThrows(InvalidSchemaException.class,
+                    () -> 
AgentMcpOutputSchemaValidator.localRegistry().getSchema(declaration).validate(json("{}")));
+            assertTrue(failure.getMessage().contains("not allowed"));
+            assertEquals(0, requests.get());
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"file:///does-not-exist/schema.json", 
"classpath:META-INF/shenyu/agent-gateway-version.properties",
+            "https://untrusted.invalid/schema"})
+    void actualLoaderRejectsNonBuiltinResources(final String uri) {
+        InvalidSchemaException failure = 
assertThrows(InvalidSchemaException.class,
+                () -> 
AgentMcpOutputSchemaValidator.localRegistry().getSchema(SchemaLocation.of(uri)));
+        assertTrue(failure.getMessage().contains("not allowed"));
+    }
+
+    @Test
+    void activeCopyPolicyRequiresStructuredContentForBusinessErrorsToo() 
throws Exception {
+        AgentMcpResultFilter filter = new AgentMcpResultFilter(config(), 
"crm.profile", json("{\"type\":\"object\"}"));
+        assertThrows(RuntimeException.class, () -> 
filter.apply(json("{\"isError\":false}")));
+        assertThrows(RuntimeException.class, () -> 
filter.apply(json("{\"isError\":true}")));
+    }
+
+    @Test
+    void limitsInstanceAndSchemaCostBeforeValidation() throws Exception {
+        AgentMcpOutputSchemaValidator validator = new 
AgentMcpOutputSchemaValidator(json("{\"type\":\"object\"}"));
+        ObjectNode large = json("{}");
+        var array = large.putArray("values");
+        for (int index = 0; index < 129; index++) {
+            array.add(index);
+        }
+        assertThrows(RuntimeException.class, () -> validator.validate(large));
+        ObjectNode longString = json("{}");
+        longString.put("value", "x".repeat(4097));
+        assertThrows(RuntimeException.class, () -> 
validator.validate(longString));
+        assertThrows(RuntimeException.class, () -> new 
AgentMcpOutputSchemaValidator(json(
+                "{\"allOf\":[{\"allOf\":[{\"allOf\":[true]}]}]}")));
+    }
+
+    private AgentGatewayResultFilterConfig config() {
+        return AgentGatewayResultFilterConfig.parse(
+                JsonParser.parseString("{\"crm.profile\":[\"/secret\"]}"), 
Set.of("crm.profile"));
+    }
+
+    private ObjectNode json(final String input) throws Exception {
+        ObjectNode value = (ObjectNode) mapper.readTree(input);
+        if (value.has("structuredContent") && !value.has("content")) {
+            value.putArray("content");
+        }
+        return value;
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpResultCopiesFilterTest.java
 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpResultCopiesFilterTest.java
new file mode 100644
index 0000000000..936ccf20c8
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpResultCopiesFilterTest.java
@@ -0,0 +1,150 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.agent.gateway.protocol;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.google.gson.JsonParser;
+import org.apache.shenyu.common.dto.AgentGatewayResultFilterConfig;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.Set;
+import java.util.stream.IntStream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class AgentMcpResultCopiesFilterTest {
+
+    private final ObjectMapper mapper = new ObjectMapper();
+
+    @Test
+    void rebuildsAllCompleteCopiesAndKeepsPrivateInputUnchanged() throws 
Exception {
+        ObjectNode original = result();
+        original.withArray("content").addObject().put("type", 
"text").put("text", " { \"secret\": \"private\", \"keep\": 3 } ");
+        original.putObject("_meta");
+        original.put("resultType", "complete");
+        original.put("isError", true);
+        ObjectNode snapshot = original.deepCopy();
+        ObjectNode filtered = filter().apply(original);
+        assertEquals(snapshot, original);
+        assertTrue(filtered.path("isError").asBoolean());
+        assertTrue(filtered.path("_meta").isEmpty());
+        for (var block : filtered.path("content")) {
+            assertEquals(filtered.path("structuredContent"), 
mapper.readTree(block.get("text").asText()));
+        }
+        assertFalse(filtered.toString().contains("private"));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"private", "{}", "[]", "null", "\"\"", " ", 
"{\"secret\":\"private\",\"keep\":3} {}",
+            "{\"secret\":\"private\",\"secret\":\"private\",\"keep\":3}", 
"{\"secret\":\"private\",\"keep\":4}",
+            "{\"keep\":3}", 
"{\"secret\":\"private\",\"keep\":3,\"extra\":\"private\"}", "{broken"})
+    void refusesUnverifiableTextWithoutLeakingItInExceptions(final String 
text) {
+        ObjectNode original = result();
+        ((ObjectNode) original.path("content").get(0)).put("text", text);
+        IllegalArgumentException error = 
assertThrows(IllegalArgumentException.class, () -> filter().apply(original));
+        assertFalse(error.getMessage().contains("private"));
+        assertEquals(null, error.getCause());
+        assertTrue(original.path("structuredContent").has("secret"));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"image", "audio", "resource", "resource_link", 
"text-extra", "annotations", "block-meta",
+            "result-meta", "extension", "absent-structured", "absent-content", 
"content-object", "invalid-error", "invalid-type"})
+    void refusesOpaqueChannelsEvenWhenTheFieldPathIsMissing(final String 
variant) {
+        ObjectNode original = unsafe(variant);
+        AgentGatewayResultFilterConfig config = 
AgentGatewayResultFilterConfig.parse(
+                JsonParser.parseString("{\"crm.profile\":[\"/missing\"]}"), 
Set.of("crm.profile"));
+        assertThrows(IllegalArgumentException.class, () -> new 
AgentMcpResultFilter(config, "crm.profile", null).apply(original));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"image", "audio", "resource", "resource_link", 
"text-extra", "annotations", "block-meta",
+            "result-meta", "extension", "absent-structured", "absent-content", 
"content-object", "invalid-error", "invalid-type"})
+    void disabledOrUnrelatedToolPlanRemainsPassThrough(final String variant) {
+        ObjectNode original = unsafe(variant);
+        assertTrue(original == new 
AgentMcpResultFilter(AgentGatewayResultFilterConfig.empty(), "crm.profile", 
null).apply(original));
+        assertTrue(original == new AgentMcpResultFilter(config(), 
"other.tool", null).apply(original));
+    }
+
+    @Test
+    void boundsCopyCountDepthAndTokenAllocation() {
+        ObjectNode many = result();
+        for (int index = 0; index < 32; index++) {
+            
many.withArray("content").add(many.path("content").get(0).deepCopy());
+        }
+        assertThrows(IllegalArgumentException.class, () -> 
filter().apply(many));
+        ObjectNode deep = result();
+        ((ObjectNode) deep.path("content").get(0)).put("text", "[".repeat(33) 
+ "0" + "]".repeat(33));
+        assertThrows(IllegalArgumentException.class, () -> 
filter().apply(deep));
+        ObjectNode tokens = result();
+        ((ObjectNode) tokens.path("content").get(0)).put("text", 
"{\"values\":[" + "0,".repeat(50000) + "0]}");
+        assertThrows(IllegalArgumentException.class, () -> 
filter().apply(tokens));
+    }
+
+    @Test
+    void thirtyTwoCallsNeverShareCopies() {
+        ObjectNode original = result();
+        IntStream.range(0, 32).parallel().forEach(index -> {
+            ObjectNode filtered = filter().apply(original);
+            ((ObjectNode) filtered.get("structuredContent")).put("owner", 
index);
+            assertFalse(filtered.toString().contains("private"));
+        });
+        assertTrue(original.path("structuredContent").has("secret"));
+        assertFalse(original.path("structuredContent").has("owner"));
+    }
+
+    private ObjectNode unsafe(final String variant) {
+        ObjectNode result = result();
+        ObjectNode block = (ObjectNode) result.path("content").get(0);
+        switch (variant) {
+            case "text-extra" -> block.put("extra", "private");
+            case "annotations" -> 
block.putObject("annotations").put("audience", "private");
+            case "block-meta" -> block.putObject("_meta").put("secret", 
"private");
+            case "result-meta" -> result.putObject("_meta").put("secret", 
"private");
+            case "extension" -> result.put("extension", "private");
+            case "absent-structured" -> result.remove("structuredContent");
+            case "absent-content" -> result.remove("content");
+            case "content-object" -> result.putObject("content");
+            case "invalid-error" -> result.put("isError", "private");
+            case "invalid-type" -> result.put("resultType", "private");
+            default -> block.put("type", variant);
+        }
+        return result;
+    }
+
+    private ObjectNode result() {
+        ObjectNode result = mapper.createObjectNode().put("isError", false);
+        result.putObject("structuredContent").put("keep", 3).put("secret", 
"private");
+        result.putArray("content").addObject().put("type", "text").put("text", 
result.get("structuredContent").toString());
+        return result;
+    }
+
+    private AgentGatewayResultFilterConfig config() {
+        return 
AgentGatewayResultFilterConfig.parse(JsonParser.parseString("{\"crm.profile\":[\"/secret\"]}"),
 Set.of("crm.profile"));
+    }
+
+    private AgentMcpResultFilter filter() {
+        return new AgentMcpResultFilter(config(), "crm.profile", null);
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpResultFilteringTest.java
 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpResultFilteringTest.java
new file mode 100644
index 0000000000..8c23c1dbde
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpResultFilteringTest.java
@@ -0,0 +1,418 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.agent.gateway.protocol;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.apache.shenyu.common.constant.Constants;
+import org.apache.shenyu.common.dto.AgentGatewayMcpConfig;
+import org.apache.shenyu.common.metrics.AgentMcpCallObserver;
+import org.apache.shenyu.plugin.agent.gateway.AgentTrafficContext;
+import org.apache.shenyu.plugin.agent.gateway.security.AgentMcpIdentity;
+import org.apache.shenyu.plugin.agent.gateway.tool.AgentToolInvocation;
+import org.apache.shenyu.plugin.agent.gateway.tool.AgentToolProvider;
+import org.apache.shenyu.plugin.agent.gateway.tool.AgentToolRegistry;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.http.MediaType;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.Sinks;
+import reactor.core.scheduler.Schedulers;
+import reactor.test.StepVerifier;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class AgentMcpResultFilteringTest {
+
+    private final ObjectMapper mapper = new ObjectMapper();
+
+    private final AtomicInteger calls = new AtomicInteger();
+
+    private final AgentTrafficContext traffic = new 
AgentTrafficContext("private-request", "mcp", "selector", "rule");
+
+    @ParameterizedTest
+    @ValueSource(strings = {"json", "sse"})
+    void localProviderRebuildsTheFilteredTextCopy(final String mode) throws 
Exception {
+        AgentToolProvider provider = new AgentToolProvider() {
+            @Override
+            public String getName() {
+                return "crm.profile";
+            }
+
+            @Override
+            public String getDescription() {
+                return "Local field-deletion fixture";
+            }
+
+            @Override
+            public JsonObject getInputSchema() {
+                return 
JsonParser.parseString("{\"type\":\"object\"}").getAsJsonObject();
+            }
+
+            @Override
+            public void validate(final JsonObject arguments) {
+                assertEquals(0, arguments.size());
+            }
+
+            @Override
+            public Mono<JsonObject> invoke(final AgentToolInvocation 
invocation) {
+                calls.incrementAndGet();
+                return 
Mono.just(JsonParser.parseString("{\"owner\":\"A\",\"secret\":\"private-marker\"}").getAsJsonObject());
+            }
+        };
+        AgentMcpHttpHandler handler = new AgentMcpHttpHandler(new 
AgentMcpDispatcher(new AgentToolRegistry(List.of(provider)), "gateway", "1"),
+                exchange -> Mono.just(new AgentMcpIdentity("A", 
Set.of("crm.profile"))));
+        MockServerWebExchange exchange = exchange("tools/call", "A");
+        StepVerifier.create(handler.handle(exchange, config(mode, "/secret", 
30000), traffic, 1)).verifyComplete();
+        ObjectNode response = reply(exchange, mode);
+        
assertFalse(response.path("result").path("structuredContent").has("secret"));
+        assertFalse(response.toString().contains("private-marker"));
+        assertEquals(response.path("result").path("structuredContent"),
+                
mapper.readTree(response.path("result").path("content").get(0).path("text").asText()));
+        assertEquals(1, calls.get());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"json", "sse"})
+    void filtersBeforeSerializationAndRebuildsVerifiedCopies(final String 
mode) throws Exception {
+        AgentMcpHttpHandler handler = handler(optionalSchema(), context -> 
Mono.just(result(context.getSubject())), true);
+        MockServerWebExchange exchange = exchange("tools/call", "A");
+        StepVerifier.create(handler.handle(exchange, config(mode, "/secret", 
30000), traffic, 1)).verifyComplete();
+        ObjectNode reply = reply(exchange, mode);
+        assertEquals(200, exchange.getResponse().getStatusCode().value());
+        
assertFalse(reply.path("result").path("structuredContent").has("secret"));
+        assertEquals("A", 
reply.path("result").path("structuredContent").path("owner").asText());
+        assertEquals(reply.path("result").path("structuredContent"),
+                
mapper.readTree(reply.path("result").path("content").get(0).path("text").asText()));
+        assertFalse(reply.toString().contains("private-marker"));
+        assertEquals("same-id", reply.path("id").asText());
+        assertEquals(1, calls.get());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"json", "sse"})
+    void requiredFieldDeletionReturnsSafeJsonErrorWithoutFallback(final String 
mode) throws Exception {
+        ObjectNode schema = mapper.createObjectNode().put("type", "object");
+        schema.putArray("required").add("secret");
+        AgentMcpHttpHandler handler = handler(schema, context -> 
Mono.just(result("A")), true);
+        MockServerWebExchange exchange = exchange("tools/call", "A");
+        AtomicInteger observations = new AtomicInteger();
+        AtomicReference<AgentMcpCallObserver.Outcome> outcome = new 
AtomicReference<>();
+        exchange.getAttributes().put(Constants.METRICS_AGENT_MCP_CALL, 
(AgentMcpCallObserver) (value, millis) -> {
+            observations.incrementAndGet();
+            outcome.set(value);
+        });
+        StepVerifier.create(handler.handle(exchange, config(mode, "/secret", 
30000), traffic, 1)).verifyComplete();
+        assertEquals(502, exchange.getResponse().getStatusCode().value());
+        assertEquals(MediaType.APPLICATION_JSON, 
exchange.getResponse().getHeaders().getContentType());
+        ObjectNode response = reply(exchange, "json");
+        assertEquals(-32024, response.path("error").path("code").asInt());
+        assertEquals("Tool result filtering failed", 
response.path("error").path("message").asText());
+        assertFalse(response.has("result"));
+        assertFalse(response.toString().contains("private-marker"));
+        assertEquals(1, observations.get());
+        assertEquals(AgentMcpCallObserver.Outcome.SERVER_ERROR, outcome.get());
+    }
+
+    @Test
+    void unsupportedSchemaFailsBeforeInvokingTheTool() {
+        ObjectNode schema = mapper.createObjectNode().put("$ref", 
"http://127.0.0.1:1/private";);
+        AgentMcpHttpHandler handler = handler(schema, context -> 
Mono.just(result("A")), true);
+        MockServerWebExchange exchange = exchange("tools/call", "A");
+        StepVerifier.create(handler.handle(exchange, config("json", "/secret", 
30000), traffic, 1)).verifyComplete();
+        assertEquals(502, exchange.getResponse().getStatusCode().value());
+        assertEquals(0, calls.get());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"json", "sse"})
+    void opaqueResultChannelsReturnOneSafeFailureWithoutRetry(final String 
mode) throws Exception {
+        for (String variant : List.of("text", "mismatch", "image", "metadata", 
"extension", "block-meta", "missing-structured")) {
+            ObjectNode value = result("A");
+            ObjectNode block = (ObjectNode) value.path("content").get(0);
+            switch (variant) {
+                case "text" -> block.put("text", "private-marker");
+                case "mismatch" -> block.put("text", 
"{\"owner\":\"A\",\"secret\":\"other-private-marker\"}");
+                case "image" -> block.put("type", "image").put("data", 
"private-marker");
+                case "metadata" -> value.putObject("_meta").put("secret", 
"private-marker");
+                case "extension" -> value.put("extension", "private-marker");
+                case "block-meta" -> block.putObject("_meta").put("secret", 
"private-marker");
+                default -> value.remove("structuredContent");
+            }
+            MockServerWebExchange exchange = exchange("tools/call", "A");
+            AtomicInteger observations = new AtomicInteger();
+            exchange.getAttributes().put(Constants.METRICS_AGENT_MCP_CALL, 
(AgentMcpCallObserver) (outcome, millis) -> {
+                assertEquals(AgentMcpCallObserver.Outcome.SERVER_ERROR, 
outcome);
+                observations.incrementAndGet();
+            });
+            final int before = calls.get();
+            StepVerifier.create(handler(null, context -> Mono.just(value), 
true)
+                    .handle(exchange, config(mode, "/secret", 30000), traffic, 
1)).verifyComplete();
+            ObjectNode response = reply(exchange, "json");
+            assertEquals(502, exchange.getResponse().getStatusCode().value());
+            assertEquals(MediaType.APPLICATION_JSON, 
exchange.getResponse().getHeaders().getContentType());
+            assertEquals(-32024, response.path("error").path("code").asInt());
+            assertEquals("Tool result filtering failed", 
response.path("error").path("message").asText());
+            assertFalse(response.toString().contains("private-marker"));
+            assertFalse(response.has("result"));
+            assertEquals(before + 1, calls.get());
+            assertEquals(1, observations.get());
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"json", "sse"})
+    void localValidationFailureUsesSafeStructuredCopies(final String mode) 
throws Exception {
+        AgentToolProvider provider = new AgentToolProvider() {
+            @Override
+            public String getName() {
+                return "crm.profile";
+            }
+
+            @Override
+            public String getDescription() {
+                return "Validation failure fixture";
+            }
+
+            @Override
+            public JsonObject getInputSchema() {
+                return 
JsonParser.parseString("{\"type\":\"object\"}").getAsJsonObject();
+            }
+
+            @Override
+            public void validate(final JsonObject arguments) {
+                throw new IllegalArgumentException("private-marker");
+            }
+
+            @Override
+            public Mono<JsonObject> invoke(final AgentToolInvocation 
invocation) {
+                calls.incrementAndGet();
+                return Mono.error(new IllegalStateException("Must not 
invoke"));
+            }
+        };
+        AgentMcpHttpHandler handler = new AgentMcpHttpHandler(new 
AgentMcpDispatcher(new AgentToolRegistry(List.of(provider)), "gateway", "1"),
+                exchange -> Mono.just(new AgentMcpIdentity("A", 
Set.of("crm.profile"))));
+        MockServerWebExchange exchange = exchange("tools/call", "A");
+        StepVerifier.create(handler.handle(exchange, config(mode, "/secret", 
30000), traffic, 1)).verifyComplete();
+        ObjectNode value = reply(exchange, mode).withObject("result");
+        assertEquals(200, exchange.getResponse().getStatusCode().value());
+        assertTrue(value.path("isError").asBoolean());
+        assertEquals("Invalid tool arguments", 
value.path("structuredContent").path("error").asText());
+        assertEquals(value.get("structuredContent"), 
mapper.readTree(value.path("content").get(0).get("text").asText()));
+        assertFalse(value.has("_meta"));
+        assertFalse(value.toString().contains("private-marker"));
+        assertEquals(0, calls.get());
+    }
+
+    @Test
+    void unauthorizedCallDoesNotCompileSchemaOrInvokeTool() {
+        AgentMcpHttpHandler handler = 
handler(mapper.createObjectNode().put("$ref", "http://127.0.0.1:1/private";),
+                context -> Mono.just(result("A")), false);
+        MockServerWebExchange exchange = exchange("tools/call", "A");
+        StepVerifier.create(handler.handle(exchange, config("json", "/secret", 
30000), traffic, 1)).verifyComplete();
+        assertEquals(403, exchange.getResponse().getStatusCode().value());
+        assertEquals(0, calls.get());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"json", "sse"})
+    void toolListRetainsOriginalSchemaWithoutExecutingTheTool(final String 
mode) throws Exception {
+        ObjectNode schema = optionalSchema();
+        MockServerWebExchange exchange = exchange("tools/list", "A");
+        StepVerifier.create(handler(schema, context -> Mono.just(result("A")), 
true)
+                .handle(exchange, config(mode, "/secret", 30000), traffic, 
1)).verifyComplete();
+        assertEquals(schema, reply(exchange, 
mode).path("result").path("tools").get(0).path("outputSchema"));
+        assertEquals(0, calls.get());
+    }
+
+    @Test
+    void inFlightCallsKeepTheirCapturedPlanDuringAnUpdate() throws Exception {
+        Sinks.One<ObjectNode> oldResult = Sinks.one();
+        AgentMcpHttpHandler handler = handler(null, context -> 
"A".equals(context.getSubject()) ? oldResult.asMono() : Mono.just(result("B")), 
true);
+        MockServerWebExchange first = exchange("tools/call", "A");
+        MockServerWebExchange second = exchange("tools/call", "B");
+        var oldCall = handler.handle(first, config("json", "/secret", 30000), 
traffic, 1).subscribe();
+        try {
+            StepVerifier.create(handler.handle(second, config("json", 
"/owner", 30000), traffic, 2)).verifyComplete();
+            oldResult.tryEmitValue(result("A"));
+            assertFalse(reply(first, 
"json").path("result").path("structuredContent").has("secret"));
+            assertTrue(reply(first, 
"json").path("result").path("structuredContent").has("owner"));
+            assertFalse(reply(second, 
"json").path("result").path("structuredContent").has("owner"));
+            assertTrue(reply(second, 
"json").path("result").path("structuredContent").has("secret"));
+        } finally {
+            oldCall.dispose();
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"json", "sse"})
+    void timeoutCancelsTheProviderWithoutReturningItsRawResult(final String 
mode) {
+        AtomicBoolean cancelled = new AtomicBoolean();
+        AgentMcpHttpHandler handler = handler(null, context -> 
Mono.<ObjectNode>never().doOnCancel(() -> cancelled.set(true)), true);
+        MockServerWebExchange exchange = exchange("tools/call", "A");
+        StepVerifier.withVirtualTime(() -> handler.handle(exchange, 
config(mode, "/secret", 100), traffic, 1))
+                .thenAwait(Duration.ofMillis(101)).verifyComplete();
+        assertEquals(504, exchange.getResponse().getStatusCode().value());
+        assertTrue(cancelled.get());
+    }
+
+    @Test
+    void cancellingOneCallDoesNotCancelItsSameIdPeer() throws Exception {
+        AtomicBoolean cancelled = new AtomicBoolean();
+        AgentMcpHttpHandler handler = handler(null, context -> 
"A".equals(context.getSubject())
+                ? Mono.<ObjectNode>never().doOnCancel(() -> 
cancelled.set(true)) : Mono.just(result("B")), true);
+        MockServerWebExchange first = exchange("tools/call", "A");
+        StepVerifier.create(handler.handle(first, config("json", "/secret", 
30000), traffic, 1))
+                .then(() -> assertEquals(1, 
calls.get())).thenCancel().verify();
+        MockServerWebExchange second = exchange("tools/call", "B");
+        StepVerifier.create(handler.handle(second, config("json", "/secret", 
30000), traffic, 1)).verifyComplete();
+        assertTrue(cancelled.get());
+        assertEquals("B", reply(second, 
"json").path("result").path("structuredContent").path("owner").asText());
+        assertEquals("same-id", reply(second, "json").path("id").asText());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"json", "sse"})
+    void responseLimitAppliesToTheFilteredSerializedResponse(final String 
mode) throws Exception {
+        AgentMcpHttpHandler handler = handler(null, context -> {
+            ObjectNode response = result("A");
+            ((ObjectNode) response.path("structuredContent")).put("secret", 
"x".repeat(2048));
+            ((ObjectNode) response.path("content").get(0)).put("text", 
response.path("structuredContent").toString());
+            return Mono.just(response);
+        }, true);
+        JsonObject settings = JsonParser.parseString(settings(mode, "/secret", 
30000)).getAsJsonObject();
+        settings.addProperty("maxResponseBytes", 1024);
+        MockServerWebExchange exchange = exchange("tools/call", "A");
+        StepVerifier.create(handler.handle(exchange, 
AgentGatewayMcpConfig.parse(settings, true), traffic, 1)).verifyComplete();
+        assertEquals(200, exchange.getResponse().getStatusCode().value());
+        assertFalse(reply(exchange, 
mode).path("result").path("structuredContent").has("secret"));
+    }
+
+    @Test
+    void sameRpcIdAcrossThirtyTwoCallsHasPrivatePlanAndResult() {
+        AgentMcpHttpHandler handler = handler(null, context -> 
Mono.just(result(context.getSubject())).delayElement(Duration.ofMillis(1)), 
true);
+        StepVerifier.create(Flux.range(0, 32).flatMap(index -> {
+            String mode = index % 2 == 0 ? "json" : "sse";
+            String path = index % 2 == 0 ? "/secret" : "/owner";
+            MockServerWebExchange exchange = exchange("tools/call", "owner-" + 
index);
+            return handler.handle(exchange, config(mode, path, 30000), 
traffic, index)
+                    .then(Mono.defer(() -> 
exchange.getResponse().getBodyAsString())).flatMap(body -> Mono.fromCallable(() 
-> {
+                        ObjectNode response = decode(body, mode);
+                        assertEquals("same-id", response.path("id").asText());
+                        assertEquals(index % 2 == 0, 
response.path("result").path("structuredContent").has("owner"));
+                        assertEquals(index % 2 != 0, 
response.path("result").path("structuredContent").has("secret"));
+                        if (index % 2 == 0) {
+                            assertEquals("owner-" + index, 
response.path("result").path("structuredContent").path("owner").asText());
+                        }
+                        return response;
+                    })).subscribeOn(Schedulers.parallel());
+        }, 8).collectList()).assertNext(results -> assertEquals(32, 
results.size())).verifyComplete();
+        assertEquals(32, calls.get());
+    }
+
+    private AgentMcpHttpHandler handler(final ObjectNode outputSchema, final 
Function<AgentMcpExecutionContext, Mono<ObjectNode>> result,
+                                       final boolean authorized) {
+        AgentMcpRemoteCatalog catalog = operation -> operation.apply(new 
AgentMcpRemoteCatalog.Snapshot() {
+            @Override
+            public Map<String, ObjectNode> definitions() {
+                ObjectNode definition = mapper.createObjectNode().put("name", 
"crm.profile").put("description", "Remote profile");
+                definition.putObject("inputSchema").put("type", "object");
+                if (Objects.nonNull(outputSchema)) {
+                    definition.set("outputSchema", outputSchema);
+                }
+                return Map.of("crm.profile", definition);
+            }
+
+            @Override
+            public Mono<ObjectNode> invoke(final String name, final JsonObject 
arguments, final AgentMcpExecutionContext context) {
+                calls.incrementAndGet();
+                return result.apply(context);
+            }
+        });
+        AgentMcpDispatcher dispatcher = new AgentMcpDispatcher(new 
AgentToolRegistry(List.of()), "gateway", "1", catalog);
+        return new AgentMcpHttpHandler(dispatcher, exchange -> Mono.just(new 
AgentMcpIdentity(exchange.getAttribute("trusted-subject"),
+                authorized ? Set.of("crm.profile") : Set.of())));
+    }
+
+    private MockServerWebExchange exchange(final String method, final String 
subject) {
+        ObjectNode body = mapper.createObjectNode().put("jsonrpc", 
"2.0").put("id", "same-id").put("method", method);
+        ObjectNode params = body.putObject("params");
+        
params.putObject("_meta").put("io.modelcontextprotocol/protocolVersion", 
"2026-07-28")
+                .putObject("io.modelcontextprotocol/clientCapabilities");
+        var builder = 
MockServerHttpRequest.post("/agent/mcp").contentType(MediaType.APPLICATION_JSON)
+                .header("Accept", "application/json, 
text/event-stream").header("MCP-Protocol-Version", 
"2026-07-28").header("Mcp-Method", method);
+        if ("tools/call".equals(method)) {
+            params.put("name", "crm.profile").putObject("arguments");
+            builder.header("Mcp-Name", "crm.profile");
+        }
+        MockServerWebExchange exchange = 
MockServerWebExchange.from(builder.body(body.toString()));
+        exchange.getAttributes().put("trusted-subject", subject);
+        return exchange;
+    }
+
+    private AgentGatewayMcpConfig config(final String mode, final String path, 
final int timeout) {
+        return 
AgentGatewayMcpConfig.parse(JsonParser.parseString(settings(mode, path, 
timeout)), true);
+    }
+
+    private String settings(final String mode, final String path, final int 
timeout) {
+        return "{\"allowedTools\":[\"crm.profile\"],\"responseMode\":\"" + 
mode + "\",\"timeoutMs\":" + timeout
+                + ",\"structuredContentRemovals\":{\"crm.profile\":[\"" + path 
+ "\"]}}";
+    }
+
+    private ObjectNode optionalSchema() {
+        ObjectNode schema = mapper.createObjectNode().put("type", "object");
+        schema.putObject("properties").putObject("owner").put("type", 
"string");
+        schema.putArray("required").add("owner");
+        return schema;
+    }
+
+    private ObjectNode result(final String owner) {
+        ObjectNode result = mapper.createObjectNode().put("isError", false);
+        result.putObject("structuredContent").put("owner", 
owner).put("secret", "private-marker");
+        result.putArray("content").addObject().put("type", "text").put("text", 
result.get("structuredContent").toString());
+        return result;
+    }
+
+    private ObjectNode reply(final MockServerWebExchange exchange, final 
String mode) throws Exception {
+        String body = exchange.getResponse().getBodyAsString().block();
+        return decode(body, mode);
+    }
+
+    private ObjectNode decode(final String body, final String mode) throws 
Exception {
+        String json = "sse".equals(mode) ? body.substring(body.indexOf("data: 
") + 6).trim() : body;
+        return (ObjectNode) mapper.readTree(json);
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpStructuredContentFilterTest.java
 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpStructuredContentFilterTest.java
new file mode 100644
index 0000000000..7eaf2a3cd0
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpStructuredContentFilterTest.java
@@ -0,0 +1,259 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.agent.gateway.protocol;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+import reactor.test.StepVerifier;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class AgentMcpStructuredContentFilterTest {
+
+    private final ObjectMapper mapper = new ObjectMapper();
+
+    @ParameterizedTest
+    @ValueSource(strings = {"", "secret", "#/secret", "~secret", "/a~", 
"/a~2", "/a~3b"})
+    void rejectsInvalidPaths(final String path) {
+        assertThrows(IllegalArgumentException.class, () -> new 
AgentMcpStructuredContentFilter(List.of(path)));
+    }
+
+    @Test
+    void rejectsNullPlanAndNullPath() {
+        assertThrows(IllegalArgumentException.class, () -> new 
AgentMcpStructuredContentFilter(null));
+        List<String> paths = new ArrayList<>();
+        paths.add(null);
+        assertThrows(IllegalArgumentException.class, () -> new 
AgentMcpStructuredContentFilter(paths));
+    }
+
+    @Test
+    void rejectsDuplicatePaths() {
+        assertThrows(IllegalArgumentException.class, () -> new 
AgentMcpStructuredContentFilter(List.of("/secret", "/secret")));
+    }
+
+    @Test
+    void rejectsExcessivePathCountLengthAndDepth() {
+        List<String> paths = new ArrayList<>();
+        for (int index = 0; index < 33; index++) {
+            paths.add("/field" + index);
+        }
+        assertThrows(IllegalArgumentException.class, () -> new 
AgentMcpStructuredContentFilter(paths));
+        assertThrows(IllegalArgumentException.class, () -> new 
AgentMcpStructuredContentFilter(List.of("/" + "a".repeat(512))));
+        assertThrows(IllegalArgumentException.class, () -> new 
AgentMcpStructuredContentFilter(List.of("/a".repeat(17))));
+    }
+
+    @Test
+    void removesOnlyStructuredFieldsAndPreservesProtocolData() throws 
JsonProcessingException {
+        ObjectNode source = 
result("{\"customer\":{\"name\":\"Ada\",\"secret\":\"private\"},\"secret\":\"top\",\"keep\":7}");
+        source.put("isError", true);
+        source.putObject("_meta").put("secret", "metadata");
+        source.putArray("content").addObject().put("type", "text").put("text", 
"{\"secret\":\"still-present\"}");
+        source.put("resultType", "complete");
+        source.put("extension", "unchanged");
+        final ObjectNode snapshot = source.deepCopy();
+        ObjectNode filtered = new 
AgentMcpStructuredContentFilter(List.of("/secret", 
"/customer/secret")).filter(source);
+        assertFalse(filtered.path("structuredContent").has("secret"));
+        
assertFalse(filtered.path("structuredContent").path("customer").has("secret"));
+        assertEquals("Ada", 
filtered.path("structuredContent").path("customer").path("name").asText());
+        assertEquals(7, 
filtered.path("structuredContent").path("keep").asInt());
+        assertEquals(source.path("content"), filtered.path("content"));
+        assertEquals(source.path("_meta"), filtered.path("_meta"));
+        assertEquals(source.path("isError"), filtered.path("isError"));
+        assertEquals(source.path("resultType"), filtered.path("resultType"));
+        assertEquals(source.path("extension"), filtered.path("extension"));
+        assertEquals(snapshot, source);
+        assertNotSame(source, filtered);
+    }
+
+    @Test
+    void disabledPlanDoesNotCopyOrRejectAnOtherwiseUntouchedResult() throws 
JsonProcessingException {
+        ObjectNode source = result("null");
+        assertSame(source, new 
AgentMcpStructuredContentFilter(List.of()).filter(source));
+    }
+
+    @Test
+    void supportsEscapedSlashesTildesAndEmptyKeys() throws 
JsonProcessingException {
+        ObjectNode source = 
result("{\"a/b\":1,\"a~b\":2,\"\":3,\"~1\":4,\"keep\":5}");
+        ObjectNode filtered = new 
AgentMcpStructuredContentFilter(List.of("/a~1b", "/a~0b", "/", 
"/~01")).filter(source);
+        assertEquals(mapper.readTree("{\"keep\":5}"), 
filtered.path("structuredContent"));
+        assertEquals(5, source.path("structuredContent").size());
+    }
+
+    @Test
+    void treatsNumericAndWildcardObjectKeysAsLiterals() throws 
JsonProcessingException {
+        ObjectNode source = result("{\"01\":1,\"*\":2,\"secret\":3}");
+        ObjectNode filtered = new 
AgentMcpStructuredContentFilter(List.of("/01", "/*")).filter(source);
+        assertEquals(mapper.readTree("{\"secret\":3}"), 
filtered.path("structuredContent"));
+    }
+
+    @Test
+    void traversesArrayObjectsWithoutRemovingOrShiftingElements() throws 
JsonProcessingException {
+        ObjectNode source = 
result("{\"items\":[{\"secret\":\"a\",\"id\":1},{\"secret\":\"b\",\"id\":2}]}");
+        ObjectNode filtered = new 
AgentMcpStructuredContentFilter(List.of("/items/0/secret", 
"/items/1/secret")).filter(source);
+        assertEquals(2, 
filtered.path("structuredContent").path("items").size());
+        assertEquals(1, 
filtered.path("structuredContent").path("items").get(0).path("id").asInt());
+        assertEquals(2, 
filtered.path("structuredContent").path("items").get(1).path("id").asInt());
+        
assertTrue(source.path("structuredContent").path("items").get(0).has("secret"));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"/items/0", "/items/-", "/items/01/secret", 
"/items/-1/secret",
+            "/items/+0/secret", "/items/2147483648/secret"})
+    void rejectsArrayElementDeletionAndInvalidArrayIndexes(final String path) 
throws JsonProcessingException {
+        ObjectNode source = result("{\"items\":[{\"secret\":\"private\"}]}");
+        ObjectNode snapshot = source.deepCopy();
+        assertThrows(IllegalArgumentException.class, () -> new 
AgentMcpStructuredContentFilter(List.of(path)).filter(source));
+        assertEquals(snapshot, source);
+    }
+
+    @Test
+    void missingOptionalPathsAndOutOfRangeParentsAreNoOps() throws 
JsonProcessingException {
+        ObjectNode source = result("{\"items\":[{\"name\":\"Ada\"}]}");
+        ObjectNode filtered = new 
AgentMcpStructuredContentFilter(List.of("/missing", "/absent/secret", 
"/items/5/secret")).filter(source);
+        assertEquals(source, filtered);
+        assertNotSame(source, filtered);
+    }
+
+    @Test
+    void resultWithoutStructuredContentRetainsItsError() {
+        ObjectNode source = mapper.createObjectNode().put("isError", true);
+        source.putArray("content").addObject().put("type", "text").put("text", 
"tool failed");
+        assertEquals(source, new 
AgentMcpStructuredContentFilter(List.of("/secret")).filter(source));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"null", "[]", "1", "true", "\"value\""})
+    void rejectsNonObjectStructuredContent(final String structured) throws 
JsonProcessingException {
+        ObjectNode source = result(structured);
+        assertThrows(IllegalArgumentException.class, () -> new 
AgentMcpStructuredContentFilter(List.of("/secret")).filter(source));
+    }
+
+    @Test
+    void incompatibleTraversalFailsWithoutMutatingOrLeakingInput() throws 
JsonProcessingException {
+        ObjectNode source = 
result("{\"secret\":\"private-value\",\"customer\":null}");
+        ObjectNode snapshot = source.deepCopy();
+        IllegalArgumentException failure = 
assertThrows(IllegalArgumentException.class,
+                () -> new AgentMcpStructuredContentFilter(List.of("/secret", 
"/customer/secret")).filter(source));
+        assertEquals(snapshot, source);
+        assertFalse(failure.getMessage().contains("private-value"));
+    }
+
+    @Test
+    void snapshotsThePlanAndOwnsEachFilteredResult() throws 
JsonProcessingException {
+        List<String> paths = new ArrayList<>(List.of("/secret"));
+        AgentMcpStructuredContentFilter filter = new 
AgentMcpStructuredContentFilter(paths);
+        paths.clear();
+        paths.add("/keep");
+        ObjectNode source = result("{\"secret\":\"private\",\"keep\":1}");
+        ObjectNode first = filter.filter(source);
+        ObjectNode second = filter.filter(source);
+        ((ObjectNode) first.path("structuredContent")).put("keep", 99);
+        assertEquals(1, second.path("structuredContent").path("keep").asInt());
+        assertTrue(source.path("structuredContent").has("secret"));
+        assertFalse(second.path("structuredContent").has("secret"));
+    }
+
+    @Test
+    void overlappingDeletesHaveNoOrderingDependentEffect() throws 
JsonProcessingException {
+        ObjectNode source = 
result("{\"customer\":{\"secret\":\"private\"},\"keep\":1}");
+        ObjectNode first = new 
AgentMcpStructuredContentFilter(List.of("/customer", 
"/customer/secret")).filter(source);
+        ObjectNode second = new 
AgentMcpStructuredContentFilter(List.of("/customer/secret", 
"/customer")).filter(source);
+        assertEquals(first, second);
+        assertFalse(first.path("structuredContent").has("customer"));
+    }
+
+    @Test
+    void rejectsExcessiveDepthBeforeCopying() {
+        ObjectNode source = mapper.createObjectNode();
+        ObjectNode nested = source.putObject("structuredContent");
+        for (int index = 0; index < 33; index++) {
+            nested = nested.putObject("nested");
+        }
+        assertThrows(IllegalArgumentException.class, () -> new 
AgentMcpStructuredContentFilter(List.of("/secret")).filter(source));
+    }
+
+    @Test
+    void rejectsExcessiveNodesAndCharactersBeforeCopying() {
+        ObjectNode source = mapper.createObjectNode();
+        ArrayNode values = 
source.putObject("structuredContent").putArray("values");
+        for (int index = 0; index < 16384; index++) {
+            values.add(index);
+        }
+        AgentMcpStructuredContentFilter filter = new 
AgentMcpStructuredContentFilter(List.of("/secret"));
+        assertThrows(IllegalArgumentException.class, () -> 
filter.filter(source));
+        ObjectNode oversized = mapper.createObjectNode();
+        oversized.putObject("structuredContent").put("secret", 
"x".repeat(2097153));
+        assertThrows(IllegalArgumentException.class, () -> 
filter.filter(oversized));
+    }
+
+    @Test
+    void rejectsCyclesAndUnsupportedNodesBeforeDeepCopying() {
+        ObjectNode source = mapper.createObjectNode();
+        source.set("structuredContent", source);
+        AgentMcpStructuredContentFilter filter = new 
AgentMcpStructuredContentFilter(List.of("/secret"));
+        assertThrows(IllegalArgumentException.class, () -> 
filter.filter(source));
+        ObjectNode pojo = mapper.createObjectNode();
+        pojo.putObject("structuredContent").putPOJO("secret", new Object());
+        assertThrows(IllegalArgumentException.class, () -> 
filter.filter(pojo));
+    }
+
+    @Test
+    void concurrentCallsWithDifferentPlansDoNotShareResults() throws 
JsonProcessingException {
+        ObjectNode source = 
result("{\"secret\":\"private\",\"keep\":\"public\"}");
+        AgentMcpStructuredContentFilter privateFilter = new 
AgentMcpStructuredContentFilter(List.of("/secret"));
+        AgentMcpStructuredContentFilter publicFilter = new 
AgentMcpStructuredContentFilter(List.of("/keep"));
+        StepVerifier.create(Flux.range(0, 32).flatMap(index -> 
Mono.fromSupplier(() -> {
+            ObjectNode filtered = (index % 2 == 0 ? privateFilter : 
publicFilter).filter(source);
+            assertEquals(index % 2 != 0, 
filtered.path("structuredContent").has("secret"));
+            assertEquals(index % 2 == 0, 
filtered.path("structuredContent").has("keep"));
+            return filtered;
+        }).subscribeOn(Schedulers.parallel())).collectList())
+                .assertNext(results -> {
+                    assertEquals(32, results.size());
+                    Set<ObjectNode> identities = Collections.newSetFromMap(new 
IdentityHashMap<>());
+                    identities.addAll(results);
+                    assertEquals(32, identities.size());
+                }).verifyComplete();
+        assertEquals(2, source.path("structuredContent").size());
+    }
+
+    private ObjectNode result(final String structured) throws 
JsonProcessingException {
+        ObjectNode result = mapper.createObjectNode();
+        result.set("structuredContent", mapper.readTree(structured));
+        return result;
+    }
+}

Reply via email to