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;
+ }
+}