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 381f75a447 Expose configured HTTP routes as Agent Gateway tools (#7572)
381f75a447 is described below
commit 381f75a44785fbb4f0faba1f98f3bf28a5295803
Author: BobSong <[email protected]>
AuthorDate: Wed Oct 21 10:38:58 2026 +0800
Expose configured HTTP routes as Agent Gateway tools (#7572)
* feat(agent-gateway): expose configured HTTP routes as MCP tools
* ci: rerun HTTP tools pull request checks
* ci: retry integrated build after dependency download failures
* ci: retrigger HTTP tools pull request checks
* ci: retrigger checks after parent POM resolution failure
* ci: retry HTTP tools checks after external httpbin failures
---
RELEASE-NOTES.md | 8 +
.../AgentGatewayRuleHandleValidatorTest.java | 16 +
.../common/dto/AgentGatewayHttpToolConfig.java | 281 +++++++++++++++++
.../shenyu/common/dto/AgentGatewayMcpConfig.java | 9 +-
.../common/dto/AgentGatewayHttpToolConfigTest.java | 136 +++++++++
.../shenyu-plugin-agent-gateway/README.md | 69 +++++
shenyu-plugin/shenyu-plugin-agent-gateway/pom.xml | 6 +
.../agent/gateway/AgentGatewayConstants.java | 5 +
.../plugin/agent/gateway/AgentGatewayPlugin.java | 10 +-
.../agent/gateway/protocol/AgentMcpDispatcher.java | 13 +
.../gateway/protocol/AgentMcpExecutionContext.java | 34 ++-
.../gateway/protocol/AgentMcpHttpHandler.java | 18 +-
.../agent/gateway/tool/AgentHttpToolExecutor.java | 338 +++++++++++++++++++++
.../agent/gateway/tool/AgentHttpToolInvoker.java} | 20 +-
.../agent/gateway/tool/AgentHttpToolProvider.java | 82 +++++
.../agent/gateway/tool/AgentToolRegistry.java | 22 ++
.../agent/gateway/AgentHttpToolRoutingTest.java | 335 ++++++++++++++++++++
.../gateway/tool/AgentHttpToolExecutorTest.java | 276 +++++++++++++++++
.../gateway/tool/AgentHttpToolNamespaceTest.java | 94 ++++++
.../shenyu/plugin/api/ShenyuPluginChain.java | 9 +
.../shenyu/web/handler/ShenyuWebHandler.java | 5 +
21 files changed, 1773 insertions(+), 13 deletions(-)
diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md
index 44f1c31e75..443a806f51 100644
--- a/RELEASE-NOTES.md
+++ b/RELEASE-NOTES.md
@@ -28,6 +28,14 @@
### Behavior Changes
+- Agent Gateway accepts opt-in `mcp.httpTools` plans for fixed existing gateway
+ HTTP routes. GET/query and POST/query or flat scalar JSON-body mappings use
+ request-local plugin cursors, attributes and captured responses; upstream
+ configuration remains owned by existing routing plugins. Caller credentials,
+ cookies and web sessions are not delegated. Results pass through existing MCP
+ authorization, filtering and terminal metrics. No per-tool Java Bean or
gateway
+ restart is required for rule updates; absent plans preserve previous
behavior.
+
- `shenyu.httpclient.keepAlive` now defaults to `true`, enabling persistent
upstream connections and reducing connection setup overhead. It previously
defaulted to `false` as a workaround for stale pooled connections being reset
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 9add7821b5..cdd09162ff 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
@@ -66,6 +66,22 @@ public final class AgentGatewayRuleHandleValidatorTest {
assertThrows(ShenyuAdminException.class, () ->
AgentGatewayRuleHandleValidator.validate(null));
}
+ @Test
+ public void testValidConfiguredHttpTool() {
+ assertDoesNotThrow(() -> AgentGatewayRuleHandleValidator.validate("""
+
{"trafficType":"mcp","mcp":{"allowedTools":["lookup"],"httpTools":{"lookup":{
+ "description":"HTTP
lookup","inputSchema":{"type":"object","properties":{}},
+
"requestConfig":{"requestTemplate":{"method":"GET","url":"/orders/status"},"argsPosition":{}}}}}}
+ """));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"null", "[]", "{\"foreign\":{}}",
"{\"lookup\":{\"future\":true}}"})
+ public void testInvalidConfiguredHttpTool(final String plans) {
+ String handle =
"{\"trafficType\":\"mcp\",\"mcp\":{\"allowedTools\":[\"lookup\"],\"httpTools\":"
+ plans + "}}";
+ assertThrows(ShenyuAdminException.class, () ->
AgentGatewayRuleHandleValidator.validate(handle));
+ }
+
@Test
public void testValidStructuredContentDeletion() {
assertDoesNotThrow(() ->
AgentGatewayRuleHandleValidator.validate("{\"trafficType\":\"mcp\",\"mcp\":{\"allowedTools\":[\"crm.profile\"],"
diff --git
a/shenyu-common/src/main/java/org/apache/shenyu/common/dto/AgentGatewayHttpToolConfig.java
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/AgentGatewayHttpToolConfig.java
new file mode 100644
index 0000000000..a58cfbe78e
--- /dev/null
+++
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/AgentGatewayHttpToolConfig.java
@@ -0,0 +1,281 @@
+/*
+ * 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 com.google.gson.JsonObject;
+import com.google.gson.JsonPrimitive;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Immutable, bounded HTTP tool plans sharing the MCP Server requestTemplate
and argsPosition vocabulary.
+ * Targets are fixed gateway paths, never caller-supplied upstream URLs.
+ */
+public final class AgentGatewayHttpToolConfig {
+
+ private final Map<String, Tool> tools;
+
+ private AgentGatewayHttpToolConfig(final Map<String, Tool> tools) {
+ this.tools = Collections.unmodifiableMap(new LinkedHashMap<>(tools));
+ }
+
+ public static AgentGatewayHttpToolConfig empty() {
+ return new AgentGatewayHttpToolConfig(Map.of());
+ }
+
+ /**
+ * Parse exact named tools at both the Admin and gateway boundary.
+ * @param element optional tool map
+ * @param allowedTools explicit rule permission names
+ * @return frozen plans
+ */
+ public static AgentGatewayHttpToolConfig parse(final JsonElement element,
final Set<String> allowedTools) {
+ if (Objects.isNull(element)) {
+ return empty();
+ }
+ JsonObject object = object(element, "httpTools");
+ if (object.size() > 16) {
+ throw invalid();
+ }
+ Map<String, Tool> plans = new LinkedHashMap<>();
+ object.entrySet().forEach(entry -> {
+ String name = entry.getKey();
+ if (!name.matches("[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}") ||
!allowedTools.contains(name)) {
+ throw invalid();
+ }
+ plans.put(name, new Tool(name, object(entry.getValue(), "tool")));
+ });
+ return new AgentGatewayHttpToolConfig(plans);
+ }
+
+ public Map<String, Tool> getTools() {
+ return tools;
+ }
+
+ private static JsonObject object(final JsonElement value, final String
field) {
+ if (Objects.isNull(value) || !value.isJsonObject()) {
+ throw new IllegalArgumentException("mcp.httpTools " + field + "
must be an object");
+ }
+ return value.getAsJsonObject();
+ }
+
+ private static void fields(final JsonObject object, final Set<String>
fields) {
+ if (!fields.containsAll(object.keySet())) {
+ throw invalid();
+ }
+ }
+
+ private static String text(final JsonObject object, final String field,
final int maximum) {
+ JsonElement value = object.get(field);
+ if (Objects.isNull(value) || !value.isJsonPrimitive() ||
!value.getAsJsonPrimitive().isString()
+ || value.getAsString().isBlank() ||
value.getAsString().length() > maximum) {
+ throw invalid();
+ }
+ return value.getAsString();
+ }
+
+ private static IllegalArgumentException invalid() {
+ return new IllegalArgumentException("mcp.httpTools contains an
unsupported or invalid bounded HTTP tool plan");
+ }
+
+ /**
+ * One fixed route and flat scalar argument mapping, independent of the
caller's mutable JSON.
+ */
+ public static final class Tool {
+
+ private final String name;
+
+ private final String description;
+
+ private final JsonObject schema;
+
+ private final String method;
+
+ private final String path;
+
+ private final Map<String, String> positions;
+
+ private final Map<String, String> types;
+
+ private Tool(final String name, final JsonObject object) {
+ fields(object, Set.of("description", "inputSchema",
"requestConfig"));
+ this.name = name;
+ description = text(object, "description", 2048);
+ schema = object(object.get("inputSchema"),
"inputSchema").deepCopy();
+ fields(schema, Set.of("type", "properties", "required",
"additionalProperties"));
+ if (!"object".equals(text(schema, "type", 16))) {
+ throw invalid();
+ }
+ if (schema.has("additionalProperties") &&
(!schema.get("additionalProperties").isJsonPrimitive()
+ ||
!schema.getAsJsonPrimitive("additionalProperties").isBoolean() ||
schema.get("additionalProperties").getAsBoolean())) {
+ throw invalid();
+ }
+ schema.addProperty("additionalProperties", false);
+ JsonObject properties = object(schema.get("properties"),
"properties");
+ if (properties.size() > 16) {
+ throw invalid();
+ }
+ Map<String, String> parsedTypes = new LinkedHashMap<>();
+ properties.entrySet().forEach(entry -> {
+ if (!entry.getKey().matches("[A-Za-z_][A-Za-z0-9_]{0,63}")) {
+ throw invalid();
+ }
+ JsonObject property = object(entry.getValue(), "property");
+ fields(property, Set.of("type", "description"));
+ String type = text(property, "type", 16);
+ if (!Set.of("string", "number", "integer",
"boolean").contains(type)) {
+ throw invalid();
+ }
+ if (property.has("description")) {
+ text(property, "description", 2048);
+ }
+ parsedTypes.put(entry.getKey(), type);
+ });
+ types = Collections.unmodifiableMap(parsedTypes);
+ if (schema.has("required")) {
+ if (!schema.get("required").isJsonArray() ||
schema.getAsJsonArray("required").size() > 16) {
+ throw invalid();
+ }
+ Set<String> unique = new HashSet<>();
+ schema.getAsJsonArray("required").forEach(value -> {
+ if (!value.isJsonPrimitive() ||
!value.getAsJsonPrimitive().isString()
+ || !types.containsKey(value.getAsString()) ||
!unique.add(value.getAsString())) {
+ throw invalid();
+ }
+ });
+ }
+ JsonObject config = object(object.get("requestConfig"),
"requestConfig");
+ fields(config, Set.of("requestTemplate", "argsPosition"));
+ JsonObject template = object(config.get("requestTemplate"),
"requestTemplate");
+ fields(template, Set.of("method", "url"));
+ method = text(template, "method", 8);
+ path = text(template, "url", 512);
+ if (!Set.of("GET", "POST").contains(method) ||
!path.matches("/[A-Za-z0-9_/-]+") || path.contains("//")) {
+ throw invalid();
+ }
+ JsonObject mapping = object(config.get("argsPosition"),
"argsPosition");
+ if (!mapping.keySet().equals(types.keySet())) {
+ throw invalid();
+ }
+ Map<String, String> parsedPositions = new LinkedHashMap<>();
+ mapping.keySet().forEach(key -> {
+ String position = text(mapping, key, 8);
+ if (!Set.of("query", "body").contains(position) ||
("GET".equals(method) && "body".equals(position))) {
+ throw invalid();
+ }
+ parsedPositions.put(key, position);
+ });
+ positions = Collections.unmodifiableMap(parsedPositions);
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public JsonObject getInputSchema() {
+ return schema.deepCopy();
+ }
+
+ public String getMethod() {
+ return method;
+ }
+
+ /**
+ * Validate the supported schema subset without coercing strings or
guessing types.
+ * @param arguments private invocation arguments
+ */
+ public void validate(final JsonObject arguments) {
+ if (!types.keySet().containsAll(arguments.keySet())) {
+ throw invalid();
+ }
+ if (schema.has("required")) {
+ schema.getAsJsonArray("required").forEach(value -> {
+ if (!arguments.has(value.getAsString())) {
+ throw invalid();
+ }
+ });
+ }
+ arguments.entrySet().forEach(entry -> {
+ if (!entry.getValue().isJsonPrimitive()) {
+ throw invalid();
+ }
+ JsonPrimitive value = entry.getValue().getAsJsonPrimitive();
+ String type = types.get(entry.getKey());
+ if (value.toString().length() > 2048 || ("string".equals(type)
&& !value.isString())
+ || ("boolean".equals(type) && !value.isBoolean())
+ || (Set.of("number", "integer").contains(type) &&
!value.isNumber())) {
+ throw invalid();
+ }
+ if (Set.of("number", "integer").contains(type)) {
+ try {
+ if ("integer".equals(type)) {
+ value.getAsBigDecimal().toBigIntegerExact();
+ } else {
+ value.getAsBigDecimal();
+ }
+ } catch (NumberFormatException | ArithmeticException
error) {
+ throw invalid();
+ }
+ }
+ });
+ }
+
+ /**
+ * Build only a query on the fixed route; encoded argument values
cannot replace the path.
+ * @param arguments validated invocation arguments
+ * @return relative route with encoded query
+ */
+ public String requestPath(final JsonObject arguments) {
+ validate(arguments);
+ StringBuilder target = new StringBuilder(path);
+ positions.forEach((key, position) -> {
+ if ("query".equals(position) && arguments.has(key)) {
+ target.append(target.indexOf("?") < 0 ? '?' :
'&').append(encode(key)).append('=').append(encode(arguments.get(key).getAsString()));
+ }
+ });
+ return target.toString();
+ }
+
+ public JsonObject requestBody(final JsonObject arguments) {
+ validate(arguments);
+ JsonObject body = new JsonObject();
+ positions.forEach((key, position) -> {
+ if ("body".equals(position) && arguments.has(key)) {
+ body.add(key, arguments.get(key).deepCopy());
+ }
+ });
+ return body;
+ }
+
+ private String encode(final String value) {
+ return URLEncoder.encode(value, StandardCharsets.UTF_8);
+ }
+ }
+}
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 ecc2067311..3fcae620d6 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
@@ -32,7 +32,7 @@ import java.util.Set;
public final class AgentGatewayMcpConfig {
private static final Set<String> FIELDS = Set.of("allowedTools",
"allowedOrigins", "responseMode", "timeoutMs", "maxRequestBytes",
"maxResponseBytes",
- "structuredContentRemovals");
+ "structuredContentRemovals", "httpTools");
private final Set<String> allowedTools;
@@ -48,6 +48,8 @@ public final class AgentGatewayMcpConfig {
private final AgentGatewayResultFilterConfig resultFilters;
+ private final AgentGatewayHttpToolConfig httpTools;
+
private AgentGatewayMcpConfig(final JsonObject object) {
allowedTools = strings(object, "allowedTools", false);
allowedOrigins = strings(object, "allowedOrigins", true);
@@ -59,6 +61,7 @@ public final class AgentGatewayMcpConfig {
maxRequestBytes = integer(object, "maxRequestBytes", 262144, 1024,
1048576);
maxResponseBytes = integer(object, "maxResponseBytes", 1048576, 1024,
4194304);
resultFilters =
AgentGatewayResultFilterConfig.parse(object.get("structuredContentRemovals"),
allowedTools);
+ httpTools = AgentGatewayHttpToolConfig.parse(object.get("httpTools"),
allowedTools);
}
/**
@@ -111,6 +114,10 @@ public final class AgentGatewayMcpConfig {
return resultFilters;
}
+ public AgentGatewayHttpToolConfig getHttpTools() {
+ return httpTools;
+ }
+
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/test/java/org/apache/shenyu/common/dto/AgentGatewayHttpToolConfigTest.java
b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/AgentGatewayHttpToolConfigTest.java
new file mode 100644
index 0000000000..e53c84868c
--- /dev/null
+++
b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/AgentGatewayHttpToolConfigTest.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.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.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Shared plan validation, fixed targets, typed arguments and immutable
configuration.
+ */
+class AgentGatewayHttpToolConfigTest {
+
+ @Test
+ void emptyPlansPreserveExistingRules() {
+ assertEquals(0, AgentGatewayHttpToolConfig.parse(null,
Set.of()).getTools().size());
+ assertEquals(0,
AgentGatewayMcpConfig.parse(JsonParser.parseString("{}"),
true).getHttpTools().getTools().size());
+ }
+
+ @Test
+ void capturesConfigurationAndReturnsPrivateSchemas() {
+ JsonObject source = plans("POST", "/orders/status");
+ AgentGatewayHttpToolConfig.Tool tool = parse(source);
+
source.getAsJsonObject("lookup").getAsJsonObject("requestConfig").getAsJsonObject("requestTemplate").addProperty("url",
"/changed");
+ tool.getInputSchema().addProperty("type", "array");
+ assertEquals("/orders/status", tool.requestPath(arguments("ABC")));
+ assertEquals("object",
tool.getInputSchema().get("type").getAsString());
+ assertEquals("ABC",
tool.requestBody(arguments("ABC")).get("id").getAsString());
+ }
+
+ @Test
+ void escapesQueryValuesWithoutChangingTheFixedRoute() {
+ AgentGatewayHttpToolConfig.Tool tool = parse(plans("GET",
"/orders/status"));
+
assertEquals("/orders/status?id=https%3A%2F%2Fevil.example%2F%3Fx%3D1%26y%3D2",
tool.requestPath(arguments("https://evil.example/?x=1&y=2")));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"https://evil.example", "//evil", "/a/../b",
"/a%2fb", "/a?target=x", "/a#x", "/a;session=x", "/a\\b", "/"})
+ void rejectsUncontrolledTargets(final String path) {
+ assertThrows(IllegalArgumentException.class, () -> parse(plans("GET",
path)));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"get", "DELETE", "CONNECT", "TRACE", "PUT"})
+ void rejectsUnsupportedMethods(final String method) {
+ assertThrows(IllegalArgumentException.class, () -> parse(plans(method,
"/orders/status")));
+ }
+
+ @Test
+ void rejectsUnmappedArgumentsAndWrongTypes() {
+ AgentGatewayHttpToolConfig.Tool tool = parse(plans("GET",
"/orders/status"));
+ assertThrows(IllegalArgumentException.class, () -> tool.validate(new
JsonObject()));
+ JsonObject bad = arguments("1");
+ bad.addProperty("extra", "injection");
+ assertThrows(IllegalArgumentException.class, () -> tool.validate(bad));
+ bad.remove("extra");
+ bad.addProperty("id", 1);
+ assertThrows(IllegalArgumentException.class, () -> tool.validate(bad));
+ assertThrows(IllegalArgumentException.class, () ->
tool.validate(arguments("x".repeat(2049))));
+ }
+
+ @Test
+ void rejectsForeignToolNamesAndUnknownExtensions() {
+ JsonObject source = plans("GET", "/orders/status");
+ assertThrows(IllegalArgumentException.class, () ->
AgentGatewayHttpToolConfig.parse(source, Set.of("different")));
+ source.getAsJsonObject("lookup").addProperty("credentials",
"not-permitted");
+ assertThrows(IllegalArgumentException.class, () -> parse(source));
+ }
+
+ @Test
+ void rejectsHeadersSchemasAndBodyMappingOnGet() {
+ JsonObject source = plans("GET", "/orders/status");
+
source.getAsJsonObject("lookup").getAsJsonObject("requestConfig").getAsJsonObject("argsPosition").addProperty("id",
"body");
+ assertThrows(IllegalArgumentException.class, () -> parse(source));
+ final JsonObject headerPlan = plans("POST", "/orders/status");
+
headerPlan.getAsJsonObject("lookup").getAsJsonObject("requestConfig").getAsJsonObject("requestTemplate").add("headers",
new JsonObject());
+ assertThrows(IllegalArgumentException.class, () -> parse(headerPlan));
+ }
+
+ @Test
+ void validatesIntegerNumberAndBooleanWithoutCoercion() {
+ JsonObject source = plans("POST", "/orders/status");
+
source.getAsJsonObject("lookup").getAsJsonObject("inputSchema").getAsJsonObject("properties").getAsJsonObject("id").addProperty("type",
"integer");
+ AgentGatewayHttpToolConfig.Tool tool = parse(source);
+ JsonObject value = new JsonObject();
+ value.addProperty("id", 12);
+ tool.validate(value);
+ value.addProperty("id", 1.5);
+ assertThrows(IllegalArgumentException.class, () ->
tool.validate(value));
+ }
+
+ private AgentGatewayHttpToolConfig.Tool parse(final JsonObject source) {
+ return AgentGatewayHttpToolConfig.parse(source,
Set.of("lookup")).getTools().get("lookup");
+ }
+
+ private JsonObject arguments(final String value) {
+ JsonObject arguments = new JsonObject();
+ arguments.addProperty("id", value);
+ return arguments;
+ }
+
+ private JsonObject plans(final String method, final String path) {
+ JsonObject source = JsonParser.parseString("""
+ {"lookup":{"description":"Look up a record",
+
"inputSchema":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]},
+
"requestConfig":{"requestTemplate":{"method":"GET","url":"/orders/status"},"argsPosition":{"id":"query"}}}}
+ """).getAsJsonObject();
+ JsonObject config =
source.getAsJsonObject("lookup").getAsJsonObject("requestConfig");
+ config.getAsJsonObject("requestTemplate").addProperty("method",
method);
+ config.getAsJsonObject("requestTemplate").addProperty("url", path);
+ config.getAsJsonObject("argsPosition").addProperty("id",
"POST".equals(method) ? "body" : "query");
+ return source;
+ }
+}
diff --git a/shenyu-plugin/shenyu-plugin-agent-gateway/README.md
b/shenyu-plugin/shenyu-plugin-agent-gateway/README.md
index 762b1951d3..ecc70d8315 100644
--- a/shenyu-plugin/shenyu-plugin-agent-gateway/README.md
+++ b/shenyu-plugin/shenyu-plugin-agent-gateway/README.md
@@ -429,6 +429,75 @@ per Server, 16 remote tools, 8 KiB request/response, 64
in-flight requests per c
request deadline bounds the individual HTTP future; cancellation does not
promise
remote business rollback, termination of arbitrary backend code or stopped
billing.
+## Configured HTTP tools
+
+The matched MCP rule can expose existing gateway HTTP routes without one Java
+Provider per tool. Plans use the MCP Server plugin's `requestTemplate` /
+`argsPosition` vocabulary, but not its session holder or blocking callback.
+
+```json
+{
+ "trafficType": "mcp",
+ "mcp": {
+ "allowedTools": ["order_http"],
+ "httpTools": {
+ "order_http": {
+ "description": "Look up an order through the existing HTTP route",
+ "inputSchema": {
+ "type": "object",
+ "properties": {"orderId": {"type": "string"}},
+ "required": ["orderId"]
+ },
+ "requestConfig": {
+ "requestTemplate": {"method": "GET", "url": "/orders/status"},
+ "argsPosition": {"orderId": "query"}
+ }
+ }
+ }
+ }
+}
+```
+
+Operators must separately configure the ordinary gateway route and its
upstream.
+The fixed URL is a relative gateway path, not an upstream address or client
+template: only letters, digits, underscores, hyphens and single slashes are
+supported initially. No query, fragment, percent escapes, dot segments,
absolute
+URLs or path substitutions are accepted. Upstream network controls remain the
+responsibility of existing routing plugins and deployment policy.
+
+The bounded first contract supports GET/query and POST/query or flat JSON-body
+mapping. Each scalar property must have an exact query/body mapping, with
string,
+boolean, number or integer types; no implicit coercion, nested body paths,
header
+templates or arbitrary JSON Schema assertions. At most 16 tools and 16
properties
+per tool; names/parameters at most 64 characters, descriptions 2048, paths 512,
+argument representations 2048. Unknown fields and unallowed names fail
validation
+in Admin and the gateway. Plans are immutable and follow the existing rule
sync;
+new requests see updates without restarting, while in-flight calls keep their
plan.
+
+Authorization and namespace collision checks precede HTTP execution. Each
+subscription forks the existing web-handler plugin snapshot with a new cursor,
+fresh attributes, a newly constructed HTTP body and a bounded private response.
+Internal markers skip both MCP entries, not the other gateway plugins. Parent
+headers/cookies/principal/web session are not delegated. Business
authentication
+must be provided by the configured HTTP route; this does not bypass downstream
+security plugins or provide per-record business authorization.
+
+Successful JSON responses become `structuredContent: {httpStatus, body}` and
the
+matching JSON text copy, then pass through the merged field-filter/metrics
path.
+No upstream response headers or cookies reach the MCP client. Non-2xx, session
+cookies, missing route completion or unsupported content are refused without
tool
+retry; body size and request construction share the configured byte budgets.
+The captured HTTP deadline propagates cancellation; its classified failure is
+HTTP 504 / JSON-RPC -32025 before the outer handler deadline. The outer
handler's
+existing deadline response remains unchanged. No rollback of remote side
effects
+or guarantees about detached backend work are made.
+
+The default remains no HTTP plans. Startup Java Providers and remote MCP tools
+remain available with exact-name collision rejection. Custom plugin chains must
+explicitly implement `fork()`; unsupported chains fail closed for HTTP
invocation
+without changing ordinary traffic. Dynamic Java/JAR loading, OTel, Token
accounting,
+field mapping, prompts/resources and model fallback are outside this feature.
+
## Optional structuredContent field deletion
Configure explicit plans in the matched MCP rule, keyed by complete exposed
tool
diff --git a/shenyu-plugin/shenyu-plugin-agent-gateway/pom.xml
b/shenyu-plugin/shenyu-plugin-agent-gateway/pom.xml
index b275174cf8..fe1ad2e805 100644
--- a/shenyu-plugin/shenyu-plugin-agent-gateway/pom.xml
+++ b/shenyu-plugin/shenyu-plugin-agent-gateway/pom.xml
@@ -42,6 +42,12 @@
</build>
<dependencies>
+ <dependency>
+ <groupId>org.apache.shenyu</groupId>
+ <artifactId>shenyu-web</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
diff --git
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/AgentGatewayConstants.java
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/AgentGatewayConstants.java
index 045861dba5..fff639bd36 100644
---
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/AgentGatewayConstants.java
+++
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/AgentGatewayConstants.java
@@ -34,6 +34,11 @@ public final class AgentGatewayConstants {
*/
public static final String REQUEST_CONTEXT_ATTRIBUTE =
AgentGatewayConstants.class.getName() + ".REQUEST_CONTEXT";
+ /**
+ * Internal-only marker preventing the HTTP child dispatch from
re-entering the MCP entry.
+ */
+ public static final String HTTP_TOOL_CALL_ATTRIBUTE =
AgentGatewayConstants.class.getName() + ".HTTP_TOOL_CALL";
+
/**
* Reactor context key containing the current request context.
*/
diff --git
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/AgentGatewayPlugin.java
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/AgentGatewayPlugin.java
index 2549a5a317..7821998cf9 100644
---
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/AgentGatewayPlugin.java
+++
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/AgentGatewayPlugin.java
@@ -26,6 +26,7 @@ import
org.apache.shenyu.plugin.agent.gateway.handler.AgentGatewayPluginDataHand
import org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpDispatcher;
import org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpHttpHandler;
import org.apache.shenyu.plugin.agent.gateway.tool.AgentToolRegistry;
+import org.apache.shenyu.plugin.agent.gateway.tool.AgentHttpToolExecutor;
import org.apache.shenyu.plugin.api.ShenyuPluginChain;
import org.apache.shenyu.plugin.api.result.ShenyuResultWrap;
import org.apache.shenyu.plugin.api.utils.WebFluxResultUtils;
@@ -78,7 +79,9 @@ public class AgentGatewayPlugin extends AbstractShenyuPlugin {
registerResponseRequestId(exchange, context.getRequestId());
}
return Mono.defer(() -> "mcp".equals(handle.getTrafficType())
- ? mcpHandler.handle(exchange, handle.getMcp(), context,
handle.getGeneration()) : chain.execute(exchange))
+ ? mcpHandler.handle(exchange, handle.getMcp(), context,
handle.getGeneration(),
+ new AgentHttpToolExecutor(exchange, chain,
handle.getMcp().getMaxRequestBytes(), handle.getMcp().getMaxResponseBytes()))
+ : chain.execute(exchange))
.contextWrite(reactorContext ->
reactorContext.put(AgentGatewayConstants.REACTOR_CONTEXT_KEY, context))
.doFinally(signal -> restorePreviousContext(exchange,
previousContext, context));
});
@@ -123,6 +126,11 @@ public class AgentGatewayPlugin extends
AbstractShenyuPlugin {
return WebFluxResultUtils.result(exchange, error);
}
+ @Override
+ public boolean skip(final ServerWebExchange exchange) {
+ return
Boolean.TRUE.equals(exchange.getAttribute(AgentGatewayConstants.HTTP_TOOL_CALL_ATTRIBUTE));
+ }
+
@Override
public String named() {
return PluginEnum.AGENT_GATEWAY.getName();
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 759a36664b..8c22aa4c53 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
@@ -30,6 +30,8 @@ import
org.apache.shenyu.plugin.agent.gateway.tool.AgentToolDefinition;
import org.apache.shenyu.plugin.agent.gateway.tool.AgentToolExecutionException;
import org.apache.shenyu.plugin.agent.gateway.tool.AgentToolInvocation;
import org.apache.shenyu.plugin.agent.gateway.tool.AgentToolRegistry;
+import org.apache.shenyu.plugin.agent.gateway.tool.AgentToolProvider;
+import org.apache.shenyu.plugin.agent.gateway.tool.AgentHttpToolProvider;
import reactor.core.publisher.Mono;
import java.util.Map;
@@ -80,6 +82,14 @@ public final class AgentMcpDispatcher {
Objects.requireNonNull(contextFactory, "contextFactory");
return Mono.defer(() -> {
AgentMcpExecutionContext context =
Objects.requireNonNull(contextFactory.get(), "execution context");
+ if (!context.getHttpTools().getTools().isEmpty()) {
+ AgentToolRegistry captured =
registry.withProviders(context.getHttpTools().getTools().values().stream()
+ .map(tool -> (AgentToolProvider) new
AgentHttpToolProvider(tool, context.getHttpInvoker())).toList());
+ AgentMcpExecutionContext bound = new
AgentMcpExecutionContext(context.getRequestId(), context.getSubject(),
context.getRuleId(),
+ context.getConfigurationVersion(),
context.getRuleTools(), context.getAllowedTools(), context.getDeadline(),
context.getResultFilters(),
+ context.getTenant().orElse(null));
+ return new AgentMcpDispatcher(captured, serverName,
serverVersion, remoteCatalog).dispatch(request, () -> bound);
+ }
if (Objects.nonNull(remoteCatalog)) {
return remoteCatalog.withSnapshot(snapshot ->
routeWithRemote(request, context, snapshot))
.contextWrite(reactorContext ->
reactorContext.put(AgentMcpExecutionContext.class, context));
@@ -89,6 +99,9 @@ public final class AgentMcpDispatcher {
}
return route(request, context).contextWrite(reactorContext ->
reactorContext.put(AgentMcpExecutionContext.class, context));
}).onErrorMap(error -> {
+ if (error instanceof
AgentHttpToolProvider.DeadlineExceededException) {
+ return failure(request, 504, -32025, "HTTP tool deadline
exceeded");
+ }
if (error instanceof TimeoutException) {
return error;
}
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 0daef70f4b..a189dcf22d 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
@@ -18,6 +18,8 @@
package org.apache.shenyu.plugin.agent.gateway.protocol;
import org.apache.shenyu.common.dto.AgentGatewayResultFilterConfig;
+import org.apache.shenyu.common.dto.AgentGatewayHttpToolConfig;
+import org.apache.shenyu.plugin.agent.gateway.tool.AgentHttpToolInvoker;
import java.util.HashSet;
import java.util.Objects;
@@ -49,6 +51,10 @@ public final class AgentMcpExecutionContext {
private final AgentGatewayResultFilterConfig resultFilters;
+ private final AgentGatewayHttpToolConfig httpTools;
+
+ private final AgentHttpToolInvoker httpInvoker;
+
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);
@@ -62,14 +68,32 @@ 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,
final AgentGatewayResultFilterConfig
resultFilters) {
- this(requestId, subject, ruleId, configurationVersion, ruleTools,
securityGrants, deadline, resultFilters, null);
+ this(requestId, subject, ruleId, configurationVersion, ruleTools,
securityGrants, deadline, resultFilters,
+ AgentGatewayHttpToolConfig.empty(), null, null);
}
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, final String tenant) {
+ this(requestId, subject, ruleId, configurationVersion, ruleTools,
securityGrants, deadline, resultFilters,
+ AgentGatewayHttpToolConfig.empty(), null, tenant);
+ }
+
+ 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, final AgentGatewayHttpToolConfig httpTools,
+ final AgentHttpToolInvoker httpInvoker) {
+ this(requestId, subject, ruleId, configurationVersion, ruleTools,
securityGrants, deadline, resultFilters, httpTools, httpInvoker, null);
+ }
+
+ 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, final AgentGatewayHttpToolConfig httpTools,
+ final AgentHttpToolInvoker httpInvoker,
final String tenant) {
this.tenant = Objects.isNull(tenant) ? null : requireText(tenant,
"tenant");
this.deadline = Objects.requireNonNull(deadline, "deadline");
+ this.httpTools = Objects.requireNonNull(httpTools, "httpTools");
+ this.httpInvoker = httpInvoker;
this.resultFilters = Objects.requireNonNull(resultFilters,
"resultFilters");
this.requestId = requireText(requestId, "requestId");
this.subject = requireText(subject, "subject");
@@ -120,6 +144,14 @@ public final class AgentMcpExecutionContext {
return resultFilters;
}
+ public AgentGatewayHttpToolConfig getHttpTools() {
+ return httpTools;
+ }
+
+ public AgentHttpToolInvoker getHttpInvoker() {
+ return httpInvoker;
+ }
+
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 1ad669eef2..11ef911aa4 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
@@ -25,6 +25,7 @@ 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.AgentMcpSecurityResolver;
+import org.apache.shenyu.plugin.agent.gateway.tool.AgentHttpToolInvoker;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferLimitException;
import org.springframework.core.io.buffer.DataBufferUtils;
@@ -86,6 +87,20 @@ public final class AgentMcpHttpHandler {
*/
public Mono<Void> handle(final ServerWebExchange exchange, final
AgentGatewayMcpConfig config,
final AgentTrafficContext traffic, final long
configurationVersion) {
+ return handle(exchange, config, traffic, configurationVersion, null);
+ }
+
+ /**
+ * Handle a rule with an optional request-owned internal HTTP executor.
+ * @param exchange matched MCP exchange
+ * @param config captured rule
+ * @param traffic request identity
+ * @param configurationVersion rule generation
+ * @param httpInvoker isolated gateway dispatch, never a client-supplied
callback
+ * @return completion linked to cancellation
+ */
+ public Mono<Void> handle(final ServerWebExchange exchange, final
AgentGatewayMcpConfig config,
+ final AgentTrafficContext traffic, final long
configurationVersion, final AgentHttpToolInvoker httpInvoker) {
return Mono.deferContextual(parentContext -> {
final Instant deadline =
Instant.now().plusMillis(config.getTimeoutMs());
Object callback =
exchange.getAttribute(Constants.METRICS_AGENT_MCP_CALL);
@@ -101,7 +116,8 @@ public final class AgentMcpHttpHandler {
observation.parsed(request);
return dispatcher.dispatch(request, () -> new
AgentMcpExecutionContext(traffic.getRequestId(),
identity.getSubject(),
traffic.getRuleId(), configurationVersion, config.getAllowedTools(),
- identity.getToolGrants(), deadline,
config.getResultFilters(), identity.getTenant().orElse(null)))
+ identity.getToolGrants(), deadline,
config.getResultFilters(), config.getHttpTools(), httpInvoker,
+ identity.getTenant().orElse(null)))
.contextWrite(observation::traceContext);
}))
.flatMap(response -> {
diff --git
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentHttpToolExecutor.java
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentHttpToolExecutor.java
new file mode 100644
index 0000000000..f96986e497
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentHttpToolExecutor.java
@@ -0,0 +1,338 @@
+/*
+ * 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.tool;
+
+import com.fasterxml.jackson.core.StreamReadConstraints;
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.StreamReadFeature;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.apache.shenyu.common.constant.Constants;
+import org.apache.shenyu.common.dto.AgentGatewayHttpToolConfig;
+import org.apache.shenyu.plugin.agent.gateway.AgentGatewayConstants;
+import org.apache.shenyu.plugin.api.ShenyuPluginChain;
+import org.apache.shenyu.plugin.api.context.ShenyuContext;
+import org.reactivestreams.Publisher;
+import org.springframework.core.io.buffer.DataBuffer;
+import org.springframework.core.io.buffer.DataBufferUtils;
+import org.springframework.core.io.buffer.DataBufferLimitException;
+import org.springframework.core.io.buffer.DataBufferFactory;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.server.reactive.AbstractServerHttpResponse;
+import org.springframework.http.server.reactive.ServerHttpRequest;
+import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
+import org.springframework.http.server.reactive.SslInfo;
+import org.springframework.http.codec.multipart.Part;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.http.HttpCookie;
+import org.springframework.web.server.ServerWebExchange;
+import org.springframework.web.server.ServerWebExchangeDecorator;
+import org.springframework.web.server.WebSession;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.security.Principal;
+import java.time.Instant;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeoutException;
+import java.util.function.Function;
+
+/**
+ * Dispatches fixed gateway routes through a fresh plugin cursor and captures
a bounded private response.
+ * It never opens a caller-selected socket or borrows the original MCP
response, attributes or body.
+ */
+public final class AgentHttpToolExecutor implements AgentHttpToolInvoker {
+
+ private final ServerWebExchange origin;
+
+ private final ShenyuPluginChain chain;
+
+ private final int maxRequestBytes;
+
+ private final int maxResponseBytes;
+
+ public AgentHttpToolExecutor(final ServerWebExchange origin, final
ShenyuPluginChain chain,
+ final int maxRequestBytes, final int
maxResponseBytes) {
+ this.origin = Objects.requireNonNull(origin, "origin");
+ this.chain = Objects.requireNonNull(chain, "chain");
+ this.maxRequestBytes = maxRequestBytes;
+ this.maxResponseBytes = maxResponseBytes;
+ }
+
+ @Override
+ public Mono<JsonObject> invoke(final AgentGatewayHttpToolConfig.Tool tool,
final AgentToolInvocation invocation) {
+ return Mono.defer(() -> {
+ Duration remaining = Duration.between(Instant.now(),
invocation.getDeadline());
+ if (remaining.isNegative() || remaining.isZero()) {
+ return Mono.error(new TimeoutException("HTTP tool deadline
expired"));
+ }
+ JsonObject arguments = invocation.getArguments();
+ byte[] body = "POST".equals(tool.getMethod()) ?
tool.requestBody(arguments).toString().getBytes(StandardCharsets.UTF_8) : new
byte[0];
+ if (body.length > maxRequestBytes) {
+ return Mono.error(new DataBufferLimitException("HTTP tool
request exceeds its configured budget"));
+ }
+ String path = tool.requestPath(arguments);
+ if (body.length + path.getBytes(StandardCharsets.UTF_8).length >
maxRequestBytes) {
+ return Mono.error(new DataBufferLimitException("HTTP tool
request exceeds its configured budget"));
+ }
+ CapturedResponse response = new
CapturedResponse(origin.getResponse().bufferFactory(), maxResponseBytes);
+ // Spring's mutate builder can expose the original writable header
map.
+ // Start with private empty headers before invoking it, never
clear the MCP request's map.
+ ServerHttpRequest seed = new
ServerHttpRequestDecorator(origin.getRequest()) {
+ @Override
+ public HttpHeaders getHeaders() {
+ return new HttpHeaders();
+ }
+
+ @Override
+ public Flux<DataBuffer> getBody() {
+ return Flux.empty();
+ }
+ };
+ ServerHttpRequest base =
seed.mutate().uri(URI.create("http://localhost" + path)).contextPath("")
+
.method(HttpMethod.valueOf(tool.getMethod())).headers(headers -> {
+ headers.clear();
+ headers.setAccept(List.of(MediaType.APPLICATION_JSON));
+ headers.setContentLength(body.length);
+ if (body.length > 0) {
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ }
+ }).build();
+ final ChildExchange child = new ChildExchange(origin, new
ToolRequest(base, body, response.bufferFactory()), response);
+ ShenyuContext context = new ShenyuContext();
+ context.setRpcType("http");
+ context.setHttpMethod(tool.getMethod());
+ context.setPath(URI.create(path).getPath());
+ context.setRealUrl(path);
+ context.setStartDateTime(LocalDateTime.now());
+ child.getAttributes().put(Constants.CONTEXT, context);
+
child.getAttributes().put(AgentGatewayConstants.HTTP_TOOL_CALL_ATTRIBUTE, true);
+ // The existing MCP Server plugin recognizes this internal-only
marker too.
+ child.getAttributes().put("MCP_TOOL_CALL", true);
+ return
chain.fork().execute(child).then(Mono.fromCallable(response::result)).timeout(remaining);
+ });
+ }
+
+ private static final class ToolRequest extends ServerHttpRequestDecorator {
+
+ private final byte[] body;
+
+ private final DataBufferFactory factory;
+
+ ToolRequest(final ServerHttpRequest delegate, final byte[] body, final
DataBufferFactory factory) {
+ super(delegate);
+ this.body = body.clone();
+ this.factory = factory;
+ }
+
+ @Override
+ public Flux<DataBuffer> getBody() {
+ return body.length == 0 ? Flux.empty() : Flux.defer(() ->
Flux.just(factory.wrap(body.clone())));
+ }
+
+ @Override
+ public MultiValueMap<String, HttpCookie> getCookies() {
+ return new LinkedMultiValueMap<>();
+ }
+
+ @Override
+ public SslInfo getSslInfo() {
+ return null;
+ }
+ }
+
+ private static final class ChildExchange extends
ServerWebExchangeDecorator {
+
+ private final ServerHttpRequest request;
+
+ private final CapturedResponse response;
+
+ private final Map<String, Object> attributes = new
ConcurrentHashMap<>();
+
+ private Function<String, String> urlTransformer = Function.identity();
+
+ ChildExchange(final ServerWebExchange origin, final ServerHttpRequest
request, final CapturedResponse response) {
+ super(origin);
+ this.request = request;
+ this.response = response;
+ }
+
+ @Override
+ public ServerHttpRequest getRequest() {
+ return request;
+ }
+
+ @Override
+ public CapturedResponse getResponse() {
+ return response;
+ }
+
+ @Override
+ public Map<String, Object> getAttributes() {
+ return attributes;
+ }
+
+ @Override
+ public <T extends Principal> Mono<T> getPrincipal() {
+ return Mono.empty();
+ }
+
+ @Override
+ public Mono<WebSession> getSession() {
+ return Mono.error(new UnsupportedOperationException("HTTP tool
routes must not access the caller's web session"));
+ }
+
+ @Override
+ public Mono<MultiValueMap<String, String>> getFormData() {
+ return Mono.just(new LinkedMultiValueMap<>());
+ }
+
+ @Override
+ public Mono<MultiValueMap<String, Part>> getMultipartData() {
+ return Mono.just(new LinkedMultiValueMap<>());
+ }
+
+ @Override
+ public Mono<Void> cleanupMultipart() {
+ return Mono.empty();
+ }
+
+ @Override
+ public boolean isNotModified() {
+ return false;
+ }
+
+ @Override
+ public boolean checkNotModified(final Instant lastModified) {
+ return false;
+ }
+
+ @Override
+ public boolean checkNotModified(final String etag) {
+ return false;
+ }
+
+ @Override
+ public boolean checkNotModified(final String etag, final Instant
lastModified) {
+ // Conditional caller headers are not delegated to this internal
HTTP request.
+ return false;
+ }
+
+ @Override
+ public String transformUrl(final String url) {
+ return urlTransformer.apply(url);
+ }
+
+ @Override
+ public void addUrlTransformer(final Function<String, String>
transformer) {
+ urlTransformer =
urlTransformer.andThen(Objects.requireNonNull(transformer, "transformer"));
+ }
+ }
+
+ private static final class CapturedResponse extends
AbstractServerHttpResponse {
+
+ private final int maximum;
+
+ private byte[] body = new byte[0];
+
+ CapturedResponse(final DataBufferFactory factory, final int maximum) {
+ super(factory);
+ this.maximum = maximum;
+ }
+
+ @Override
+ public <T> T getNativeResponse() {
+ throw new UnsupportedOperationException("HTTP tools expose only an
isolated response");
+ }
+
+ @Override
+ protected void applyStatusCode() {
+ }
+
+ @Override
+ protected void applyHeaders() {
+ }
+
+ @Override
+ protected void applyCookies() {
+ }
+
+ @Override
+ protected Mono<Void> writeWithInternal(final Publisher<? extends
DataBuffer> publisher) {
+ // Flux wrapping prevents the join helper's single-Mono fast path
from bypassing its byte limit.
+ return DataBufferUtils.join(Flux.from(publisher),
maximum).doOnNext(buffer -> {
+ try {
+ body = new byte[buffer.readableByteCount()];
+ buffer.read(body);
+ } finally {
+ DataBufferUtils.release(buffer);
+ }
+ }).then();
+ }
+
+ @Override
+ protected Mono<Void> writeAndFlushWithInternal(final Publisher<?
extends Publisher<? extends DataBuffer>> publisher) {
+ return
writeWithInternal(Flux.from(publisher).concatMap(Flux::from));
+ }
+
+ JsonObject result() throws IOException {
+ if (!isCommitted()) {
+ throw new AgentToolExecutionException("HTTP tool route
completed without a response");
+ }
+ int status = Objects.isNull(getStatusCode()) ?
HttpStatus.OK.value() : getStatusCode().value();
+ if (status < 200 || status >= 300) {
+ throw new AgentToolExecutionException("HTTP tool route did not
succeed");
+ }
+ if (!getCookies().isEmpty()) {
+ throw new AgentToolExecutionException("HTTP tool routes must
not create sessions or response cookies");
+ }
+ JsonObject result = new JsonObject();
+ result.addProperty("httpStatus", status);
+ if (body.length > 0) {
+ MediaType type = getHeaders().getContentType();
+ if (Objects.isNull(type) ||
!MediaType.APPLICATION_JSON.isCompatibleWith(type)
+ || Objects.nonNull(type.getCharset()) &&
!StandardCharsets.UTF_8.equals(type.getCharset())) {
+ throw new AgentToolExecutionException("HTTP tools require
a UTF-8 JSON result");
+ }
+ String text =
StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT)
+
.onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(body)).toString();
+ JsonFactory factory =
JsonFactory.builder().enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION)
+
.streamReadConstraints(StreamReadConstraints.builder().maxNestingDepth(32).maxStringLength(maximum).build()).build();
+ ObjectMapper mapper = new
ObjectMapper(factory).enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
+ result.add("body",
JsonParser.parseString(mapper.readTree(text).toString()));
+ }
+ return result;
+ }
+ }
+}
diff --git
a/shenyu-plugin/shenyu-plugin-api/src/main/java/org/apache/shenyu/plugin/api/ShenyuPluginChain.java
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentHttpToolInvoker.java
similarity index 58%
copy from
shenyu-plugin/shenyu-plugin-api/src/main/java/org/apache/shenyu/plugin/api/ShenyuPluginChain.java
copy to
shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentHttpToolInvoker.java
index e322b56617..ab9713e37f 100644
---
a/shenyu-plugin/shenyu-plugin-api/src/main/java/org/apache/shenyu/plugin/api/ShenyuPluginChain.java
+++
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentHttpToolInvoker.java
@@ -15,21 +15,23 @@
* limitations under the License.
*/
-package org.apache.shenyu.plugin.api;
+package org.apache.shenyu.plugin.agent.gateway.tool;
-import org.springframework.web.server.ServerWebExchange;
+import com.google.gson.JsonObject;
+import org.apache.shenyu.common.dto.AgentGatewayHttpToolConfig;
import reactor.core.publisher.Mono;
/**
- * the shenyu plugin chain.
+ * Request-owned dispatch into configured gateway HTTP routes.
*/
-public interface ShenyuPluginChain {
+@FunctionalInterface
+public interface AgentHttpToolInvoker {
/**
- * Delegate to the next {@code WebFilter} in the chain.
- *
- * @param exchange the current server exchange
- * @return {@code Mono<Void>} to indicate when request handling is complete
+ * Execute a captured plan once, inheriting the subscriber's context and
cancellation.
+ * @param tool immutable configured target
+ * @param invocation trusted arguments and deadline
+ * @return private business JSON object
*/
- Mono<Void> execute(ServerWebExchange exchange);
+ Mono<JsonObject> invoke(AgentGatewayHttpToolConfig.Tool tool,
AgentToolInvocation invocation);
}
diff --git
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentHttpToolProvider.java
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentHttpToolProvider.java
new file mode 100644
index 0000000000..7d29710471
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentHttpToolProvider.java
@@ -0,0 +1,82 @@
+/*
+ * 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.tool;
+
+import com.google.gson.JsonObject;
+import org.apache.shenyu.common.dto.AgentGatewayHttpToolConfig;
+import reactor.core.publisher.Mono;
+
+import java.util.Objects;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * A configured HTTP tool reusing the existing local authorization and
result-filter path.
+ */
+public final class AgentHttpToolProvider implements AgentToolProvider {
+
+ private final AgentGatewayHttpToolConfig.Tool tool;
+
+ private final AgentHttpToolInvoker invoker;
+
+ public AgentHttpToolProvider(final AgentGatewayHttpToolConfig.Tool tool,
final AgentHttpToolInvoker invoker) {
+ this.tool = Objects.requireNonNull(tool, "tool");
+ this.invoker = invoker;
+ }
+
+ @Override
+ public String getName() {
+ return tool.getName();
+ }
+
+ @Override
+ public String getDescription() {
+ return tool.getDescription();
+ }
+
+ @Override
+ public JsonObject getInputSchema() {
+ return tool.getInputSchema();
+ }
+
+ @Override
+ public void validate(final JsonObject arguments) {
+ tool.validate(arguments);
+ }
+
+ @Override
+ public Mono<JsonObject> invoke(final AgentToolInvocation invocation) {
+ return Mono.defer(() -> {
+ if (Objects.isNull(invoker)) {
+ return Mono.error(new IllegalStateException("Internal HTTP
dispatch is not installed"));
+ }
+ return invoker.invoke(tool,
invocation).onErrorMap(TimeoutException.class, error -> new
DeadlineExceededException());
+ });
+ }
+
+ /**
+ * HTTP-specific deadline marker; other providers retain their existing
error classification.
+ */
+ public static final class DeadlineExceededException extends
RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ private DeadlineExceededException() {
+ super("HTTP tool deadline exceeded");
+ }
+ }
+}
diff --git
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentToolRegistry.java
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentToolRegistry.java
index fb98efeb77..61acff527f 100644
---
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentToolRegistry.java
+++
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentToolRegistry.java
@@ -54,6 +54,11 @@ public final class AgentToolRegistry {
definitions = Collections.unmodifiableMap(metadata);
}
+ private AgentToolRegistry(final Map<String, AgentToolProvider> providers,
final Map<String, AgentToolDefinition> definitions) {
+ this.providers = Collections.unmodifiableMap(new
LinkedHashMap<>(providers));
+ this.definitions = Collections.unmodifiableMap(new
LinkedHashMap<>(definitions));
+ }
+
/**
* List only registered tools allowed by the trusted authorization
snapshot.
* @param allowedTools effective permission set, not client-supplied
metadata
@@ -95,6 +100,23 @@ public final class AgentToolRegistry {
return providers.containsKey(name);
}
+ /**
+ * Add request-local configured providers without changing startup
registrations.
+ * @param additions captured HTTP tool providers
+ * @return independent namespace, rejecting collisions
+ */
+ public AgentToolRegistry withProviders(final Collection<AgentToolProvider>
additions) {
+ AgentToolRegistry extra = new AgentToolRegistry(additions);
+ if
(extra.providers.keySet().stream().anyMatch(providers::containsKey)) {
+ throw new IllegalArgumentException("Tool namespace collision");
+ }
+ Map<String, AgentToolProvider> combined = new
LinkedHashMap<>(providers);
+ combined.putAll(extra.providers);
+ Map<String, AgentToolDefinition> metadata = new
LinkedHashMap<>(definitions);
+ metadata.putAll(extra.definitions);
+ return new AgentToolRegistry(combined, metadata);
+ }
+
/**
* Validate remote metadata with the same bounded local-schema contract.
* @param name exposed tool name
diff --git
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/AgentHttpToolRoutingTest.java
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/AgentHttpToolRoutingTest.java
new file mode 100644
index 0000000000..78684007ee
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/AgentHttpToolRoutingTest.java
@@ -0,0 +1,335 @@
+/*
+ * 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;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.apache.shenyu.common.config.ShenyuConfig;
+import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.SelectorData;
+import org.apache.shenyu.common.metrics.AgentMcpCallObserver.Outcome;
+import
org.apache.shenyu.plugin.agent.gateway.handler.AgentGatewayPluginDataHandler;
+import org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpCallTracer;
+import org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpDispatcher;
+import
org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpExecutionContext;
+import org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpHttpHandler;
+import org.apache.shenyu.plugin.agent.gateway.protocol.AgentMcpRequestParser;
+import org.apache.shenyu.plugin.agent.gateway.security.AgentMcpIdentity;
+import org.apache.shenyu.plugin.agent.gateway.tool.AgentToolRegistry;
+import org.apache.shenyu.plugin.api.ShenyuPlugin;
+import org.apache.shenyu.plugin.api.ShenyuPluginChain;
+import org.apache.shenyu.web.handler.ShenyuWebHandler;
+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 org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Sinks;
+import reactor.test.StepVerifier;
+import reactor.util.context.Context;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
+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.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Production Agent Gateway execution over the actual web-handler cursor.
+ * The fixture selects the rule directly; it does not claim an Admin/WebSocket
network test.
+ */
+class AgentHttpToolRoutingTest {
+
+ @ParameterizedTest
+ @ValueSource(strings = {"json", "sse"})
+ void preservesTrustedTenantThroughConfiguredHttpDispatch(final String
mode) {
+ AtomicInteger calls = new AtomicInteger();
+ Fixture fixture = new Fixture(mode, child ->
Mono.deferContextual(context -> {
+ AgentMcpExecutionContext trusted =
context.get(AgentMcpExecutionContext.class);
+ assertEquals("tenant-a", trusted.getTenant().orElseThrow());
+ assertEquals("trusted", trusted.getSubject());
+ assertEquals(Set.of("lookup"), trusted.getAllowedTools());
+ calls.incrementAndGet();
+ return write(child, "{\"orderId\":\"A\"}");
+ }), null, "tenant-a");
+ MockServerWebExchange call = request("tools/call", "A", false);
+ StepVerifier.create(fixture.handler.handle(call)).verifyComplete();
+ assertEquals(200, call.getResponse().getStatusCode().value());
+ assertEquals(1, calls.get());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"json", "sse"})
+ void preservesTracingContextThroughConfiguredHttpDispatch(final String
mode) {
+ final Object span = new Object();
+ final AtomicInteger starts = new AtomicInteger();
+ final AtomicInteger finishes = new AtomicInteger();
+ final AtomicInteger calls = new AtomicInteger();
+ AgentMcpCallTracer tracer = parent -> {
+ assertEquals("caller-parent", parent.get("test-parent"));
+ starts.incrementAndGet();
+ return new AgentMcpCallTracer.Trace() {
+ @Override
+ public Context attach(final Context context) {
+ return context.put("test-span", span);
+ }
+
+ @Override
+ public void finish(final Outcome outcome) {
+ assertEquals(Outcome.SUCCESS, outcome);
+ finishes.incrementAndGet();
+ }
+ };
+ };
+ Fixture fixture = new Fixture(mode, child ->
Mono.deferContextual(context -> {
+ assertEquals("caller-parent", context.get("test-parent"));
+ assertSame(span, context.get("test-span"));
+ calls.incrementAndGet();
+ return write(child, "{\"orderId\":\"A\"}");
+ }), tracer);
+ MockServerWebExchange call = request("tools/call", "A", false);
+
StepVerifier.create(fixture.handler.handle(call).contextWrite(Context.of("test-parent",
"caller-parent"))).verifyComplete();
+ assertEquals(200, call.getResponse().getStatusCode().value());
+ assertEquals("A",
result(call).getAsJsonObject("structuredContent").getAsJsonObject("body").get("orderId").getAsString());
+ assertEquals(1, calls.get());
+ assertEquals(1, starts.get());
+ assertEquals(1, finishes.get());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"json", "sse"})
+ void listsAndCallsConfiguredHttpToolsWithoutPerToolJavaBeans(final String
mode) {
+ AtomicInteger calls = new AtomicInteger();
+ Fixture fixture = new Fixture(mode, child -> {
+ calls.incrementAndGet();
+ assertEquals("/orders/status",
child.getRequest().getURI().getPath());
+
assertFalse(child.getRequest().getHeaders().containsKey("Mcp-Name"));
+ return write(child, "{\"orderId\":\"" +
child.getRequest().getQueryParams().getFirst("id") +
"\",\"privateNote\":\"secret\"}");
+ });
+ MockServerWebExchange list = request("tools/list", "A", false);
+ StepVerifier.create(fixture.handler.handle(list)).verifyComplete();
+
assertTrue(list.getResponse().getBodyAsString().block().contains("lookup"));
+ assertEquals(0, calls.get());
+ MockServerWebExchange call = request("tools/call", "A", false);
+ StepVerifier.create(fixture.handler.handle(call)).verifyComplete();
+ assertEquals(200, call.getResponse().getStatusCode().value());
+ assertEquals("A",
result(call).getAsJsonObject("structuredContent").getAsJsonObject("body").get("orderId").getAsString());
+ assertEquals(1, calls.get());
+
assertFalse(call.getAttributes().containsKey(AgentGatewayConstants.HTTP_TOOL_CALL_ATTRIBUTE));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"json", "sse"})
+ void preservesAuthorizationBeforeTheHttpSideEffect(final String mode) {
+ AtomicInteger calls = new AtomicInteger();
+ Fixture fixture = new Fixture(mode, child -> {
+ calls.incrementAndGet();
+ return write(child, "{}");
+ });
+ MockServerWebExchange denied = request("tools/call", "A", true);
+ StepVerifier.create(fixture.handler.handle(denied)).verifyComplete();
+ assertEquals(403, denied.getResponse().getStatusCode().value());
+ assertEquals(0, calls.get());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"json", "sse"})
+ void filtersHttpResultAndItsTextCopyUsingTheMergedPolicy(final String
mode) {
+ Fixture fixture = new Fixture(mode, child -> write(child,
"{\"id\":\"A\",\"privateNote\":\"secret\"}"));
+ JsonObject settings = config(mode, "/orders/status");
+ settings.getAsJsonObject("mcp").add("structuredContentRemovals",
JsonParser.parseString("{\"lookup\":[\"/body/privateNote\"]}"));
+ fixture.update(settings);
+ MockServerWebExchange call = request("tools/call", "A", false);
+ StepVerifier.create(fixture.handler.handle(call)).verifyComplete();
+ assertEquals(200, call.getResponse().getStatusCode().value());
+ String body = call.getResponse().getBodyAsString().block();
+ assertFalse(body.contains("privateNote"));
+ assertFalse(body.contains("secret"));
+ assertEquals("A",
result(call).getAsJsonObject("structuredContent").getAsJsonObject("body").get("id").getAsString());
+ }
+
+ @Test
+ void
configurationChangesKeepTheInFlightTargetAndUseNewDefinitionsWithoutRestart() {
+ final Sinks.One<String> release = Sinks.one();
+ final AtomicReference<String> firstTarget = new AtomicReference<>();
+ Fixture fixture = new Fixture("json", child -> {
+ String path = child.getRequest().getURI().getPath();
+ if ("/orders/status".equals(path)) {
+ firstTarget.set(path);
+ return release.asMono().flatMap(value -> write(child,
"{\"target\":\"" + value + "\"}"));
+ }
+ return write(child, "{\"target\":\"new\"}");
+ });
+ MockServerWebExchange old = request("tools/call", "A", false);
+ StepVerifier.create(fixture.handler.handle(old)).then(() -> {
+ assertEquals("/orders/status", firstTarget.get());
+ fixture.update(config("json", "/orders/updated"));
+ MockServerWebExchange next = request("tools/call", "B", false);
+ StepVerifier.create(fixture.handler.handle(next)).verifyComplete();
+ assertEquals("new",
result(next).getAsJsonObject("structuredContent").getAsJsonObject("body").get("target").getAsString());
+ assertEquals(Sinks.EmitResult.OK, release.tryEmitValue("old"));
+ }).verifyComplete();
+ assertEquals("old",
result(old).getAsJsonObject("structuredContent").getAsJsonObject("body").get("target").getAsString());
+ }
+
+ @Test
+ void parallelSameRpcIdsHaveIndependentCursorsAndResponses() {
+ Fixture fixture = new Fixture("json", child -> write(child,
"{\"id\":\"" + child.getRequest().getQueryParams().getFirst("id") + "\"}"));
+ StepVerifier.create(Flux.range(0, 32).flatMap(index -> {
+ MockServerWebExchange exchange = request("tools/call", "order" +
index, false);
+ return fixture.handler.handle(exchange).then(Mono.fromSupplier(()
-> result(exchange).getAsJsonObject("structuredContent")
+ .getAsJsonObject("body").get("id").getAsString()));
+ }).collectList()).assertNext(values -> assertEquals(32,
Set.copyOf(values).size())).verifyComplete();
+ }
+
+ @Test
+ void ordinaryHttpTrafficStillPassesThroughTheOriginalChain() {
+ Fixture fixture = new Fixture("json", child -> write(child,
"{\"ordinary\":true}"));
+ MockServerWebExchange exchange =
MockServerWebExchange.from(MockServerHttpRequest.get("/orders/status").build());
+ StepVerifier.create(fixture.handler.handle(exchange)).verifyComplete();
+ assertEquals("{\"ordinary\":true}",
exchange.getResponse().getBodyAsString().block());
+ }
+
+ private MockServerWebExchange request(final String method, final String
id, final boolean denied) {
+ String body =
"{\"jsonrpc\":\"2.0\",\"id\":\"same-client-id\",\"method\":\"" + method
+ +
"\",\"params\":{\"name\":\"lookup\",\"arguments\":{\"id\":\"" + id +
"\"},\"_meta\":{"
+ + "\"io.modelcontextprotocol/protocolVersion\":\"" +
AgentMcpRequestParser.VERSION +
"\",\"io.modelcontextprotocol/clientCapabilities\":{}}}}";
+ MockServerHttpRequest.BodyBuilder builder =
MockServerHttpRequest.post("/agent").contentType(MediaType.APPLICATION_JSON)
+ .header("Accept", "application/json,
text/event-stream").header("MCP-Protocol-Version",
AgentMcpRequestParser.VERSION)
+ .header("Mcp-Method", method).header("Test-Denied",
Boolean.toString(denied));
+ if ("tools/call".equals(method)) {
+ builder.header("Mcp-Name", "lookup");
+ }
+ return MockServerWebExchange.from(builder.body(body));
+ }
+
+ private JsonObject result(final MockServerWebExchange exchange) {
+ String wire = exchange.getResponse().getBodyAsString().block();
+ String json = wire.startsWith("event:") ?
wire.substring(wire.indexOf("data: ") + 6).trim() : wire;
+ return
JsonParser.parseString(json).getAsJsonObject().getAsJsonObject("result");
+ }
+
+ private static JsonObject config(final String mode, final String path) {
+ JsonObject config = JsonParser.parseString("""
+
{"trafficType":"mcp","mcp":{"allowedTools":["lookup"],"httpTools":{"lookup":{"description":"Lookup
an order",
+
"inputSchema":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]},
+
"requestConfig":{"requestTemplate":{"method":"GET","url":"/orders/status"},"argsPosition":{"id":"query"}}}}}}
+ """).getAsJsonObject();
+ JsonObject mcp = config.getAsJsonObject("mcp");
+ mcp.addProperty("responseMode", mode);
+
mcp.getAsJsonObject("httpTools").getAsJsonObject("lookup").getAsJsonObject("requestConfig")
+ .getAsJsonObject("requestTemplate").addProperty("url", path);
+ return config;
+ }
+
+ private static Mono<Void> write(final ServerWebExchange exchange, final
String body) {
+
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
+ return
exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8))));
+ }
+
+ private static final class Fixture {
+
+ private final SelectedGateway gateway;
+
+ private final ShenyuWebHandler handler;
+
+ Fixture(final String mode, final Function<ServerWebExchange,
Mono<Void>> action) {
+ this(mode, action, null);
+ }
+
+ Fixture(final String mode, final Function<ServerWebExchange,
Mono<Void>> action, final AgentMcpCallTracer tracer) {
+ this(mode, action, tracer, null);
+ }
+
+ Fixture(final String mode, final Function<ServerWebExchange,
Mono<Void>> action, final AgentMcpCallTracer tracer, final String tenant) {
+ AgentMcpHttpHandler mcp = new AgentMcpHttpHandler(new
AgentMcpDispatcher(new AgentToolRegistry(List.of()), "server", "1"),
+ exchange -> {
+ Set<String> grants =
"true".equals(exchange.getRequest().getHeaders().getFirst("Test-Denied")) ?
Set.of() : Set.of("lookup");
+ return Mono.just(Objects.isNull(tenant) ? new
AgentMcpIdentity("trusted", grants) : new AgentMcpIdentity(tenant, "trusted",
grants, Set.of()));
+ }, tracer);
+ gateway = new SelectedGateway(mcp);
+ update(config(mode, "/orders/status"));
+ handler = new ShenyuWebHandler(List.of(gateway, new
Route(action)), null, new ShenyuConfig());
+ }
+
+ void update(final JsonObject config) {
+ RuleData rule = new RuleData();
+ rule.setId(UUID.randomUUID().toString());
+ rule.setSelectorId("http-selector");
+ rule.setHandle(config.toString());
+ new AgentGatewayPluginDataHandler().handlerRule(rule);
+ gateway.rule.set(rule);
+ }
+ }
+
+ private static final class SelectedGateway extends AgentGatewayPlugin {
+
+ private final AtomicReference<RuleData> rule = new AtomicReference<>();
+
+ SelectedGateway(final AgentMcpHttpHandler handler) {
+ super(handler);
+ }
+
+ @Override
+ public Mono<Void> execute(final ServerWebExchange exchange, final
ShenyuPluginChain chain) {
+ if (!"/agent".equals(exchange.getRequest().getURI().getPath())) {
+ return chain.execute(exchange);
+ }
+ SelectorData selector = new SelectorData();
+ selector.setId("http-selector");
+ selector.setContinued(true);
+ return doExecute(exchange, chain, selector, rule.get());
+ }
+ }
+
+ private static final class Route implements ShenyuPlugin {
+
+ private final Function<ServerWebExchange, Mono<Void>> action;
+
+ Route(final Function<ServerWebExchange, Mono<Void>> action) {
+ this.action = action;
+ }
+
+ @Override
+ public Mono<Void> execute(final ServerWebExchange exchange, final
ShenyuPluginChain chain) {
+ return action.apply(exchange);
+ }
+
+ @Override
+ public int getOrder() {
+ return 200;
+ }
+
+ @Override
+ public String named() {
+ return "test-http-route";
+ }
+ }
+}
diff --git
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentHttpToolExecutorTest.java
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentHttpToolExecutorTest.java
new file mode 100644
index 0000000000..17c1f91d71
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentHttpToolExecutorTest.java
@@ -0,0 +1,276 @@
+/*
+ * 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.tool;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.apache.shenyu.common.dto.AgentGatewayHttpToolConfig;
+import org.apache.shenyu.plugin.agent.gateway.AgentGatewayConstants;
+import org.apache.shenyu.plugin.api.ShenyuPluginChain;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.core.io.buffer.DataBufferLimitException;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.HttpCookie;
+import org.springframework.http.MediaType;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+import reactor.core.scheduler.Schedulers;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.Set;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicBoolean;
+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;
+
+/**
+ * Fresh cursors, independent exchanges/responses, bounded capture and
downstream cancellation.
+ */
+class AgentHttpToolExecutorTest {
+
+ @Test
+ void doesNotReuseClientBodyHeadersCookiesAttributesOrResponse() {
+ final MockServerWebExchange origin = origin();
+ origin.getAttributes().put("private", "parent-only");
+ RecordingChain chain = new RecordingChain(child -> {
+ assertFalse(child.getAttributes().containsKey("private"));
+
assertTrue(Boolean.TRUE.equals(child.getAttribute(AgentGatewayConstants.HTTP_TOOL_CALL_ATTRIBUTE)));
+ assertTrue(child.getRequest().getCookies().isEmpty());
+
assertFalse(child.getRequest().getHeaders().containsKey("Authorization"));
+ assertFalse(child.getRequest().getHeaders().containsKey("Cookie"));
+
assertFalse(child.getRequest().getHeaders().containsKey("Mcp-Name"));
+ assertEquals("/orders/status?id=A%26B",
child.getRequest().getURI().getRawPath() + "?" +
child.getRequest().getURI().getRawQuery());
+ child.getAttributes().put("private", "child");
+ return write(child, "{\"value\":\"ok\"}");
+ });
+ StepVerifier.create(executor(origin, chain, 4096).invoke(tool("GET"),
invocation("A&B")))
+ .assertNext(result -> assertEquals("ok",
result.getAsJsonObject("body").get("value").getAsString())).verifyComplete();
+ assertEquals("parent-only", origin.getAttribute("private"));
+ assertFalse(origin.getResponse().isCommitted());
+ assertEquals("Bearer must-not-forward",
origin.getRequest().getHeaders().getFirst("Authorization"));
+ assertEquals("private=session",
origin.getRequest().getHeaders().getFirst("Cookie"));
+ assertEquals(1, chain.forks.get());
+ assertEquals(0, chain.parentExecutions.get());
+ }
+
+ @Test
+ void mapsPostBodyWithoutBorrowingTheMcpBody() {
+ RecordingChain chain = new RecordingChain(child ->
org.springframework.core.io.buffer.DataBufferUtils.join(child.getRequest().getBody())
+ .flatMap(buffer -> {
+ try {
+ byte[] bytes = new byte[buffer.readableByteCount()];
+ buffer.read(bytes);
+ assertEquals("{\"id\":\"A\"}", new String(bytes,
StandardCharsets.UTF_8));
+ } finally {
+
org.springframework.core.io.buffer.DataBufferUtils.release(buffer);
+ }
+ return write(child, "{\"posted\":true}");
+ }));
+ StepVerifier.create(executor(origin(), chain,
4096).invoke(tool("POST"), invocation("A")))
+ .assertNext(result ->
assertTrue(result.getAsJsonObject("body").get("posted").getAsBoolean())).verifyComplete();
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {0, 1, 2})
+ void conditionalChecksNeverBorrowOrMutateTheMcpResponse(final int variant)
{
+ final MockServerWebExchange origin =
MockServerWebExchange.from(MockServerHttpRequest.post("/agent")
+ .header("If-Match",
"\"different-parent-etag\"").header("If-Unmodified-Since", "Wed, 21 Oct 2015
07:28:00 GMT"));
+ RecordingChain chain = new RecordingChain(child -> {
+ assertEquals("\"different-parent-etag\"",
origin.getRequest().getHeaders().getFirst("If-Match"));
+ boolean modified = switch (variant) {
+ case 0 -> child.checkNotModified("\"parent-etag\"");
+ case 1 ->
child.checkNotModified(Instant.parse("2026-10-12T00:00:00Z"));
+ default -> child.checkNotModified("\"parent-etag\"",
Instant.parse("2026-10-12T00:00:00Z"));
+ };
+ assertFalse(modified);
+ assertFalse(child.isNotModified());
+ return write(child, "{}");
+ });
+ StepVerifier.create(executor(origin, chain, 4096).invoke(tool("GET"),
invocation("A"))).expectNextCount(1).verifyComplete();
+ assertFalse(origin.isNotModified());
+ assertEquals(null, origin.getResponse().getStatusCode());
+ assertTrue(origin.getResponse().getHeaders().isEmpty());
+ assertFalse(origin.getResponse().isCommitted());
+ }
+
+ @Test
+ void urlTransformersAndMultipartCleanupNeverBorrowTheParentExchange() {
+ final MockServerWebExchange origin = origin();
+ origin.addUrlTransformer(url -> "parent:" + url);
+ RecordingChain chain = new RecordingChain(child -> {
+ assertEquals("route", child.transformUrl("route"));
+ child.addUrlTransformer(url -> "child:" + url);
+ assertEquals("child:route", child.transformUrl("route"));
+ assertEquals("parent:route", origin.transformUrl("route"));
+ return child.cleanupMultipart().then(write(child, "{}"));
+ });
+ StepVerifier.create(executor(origin, chain, 4096).invoke(tool("GET"),
invocation("A"))).expectNextCount(1).verifyComplete();
+ assertEquals("parent:route", origin.transformUrl("route"));
+ }
+
+ @Test
+ void
sameClientIdAndConcurrentSubscriptionsKeepPrivateStateAndFreshCursors() {
+ final RecordingChain chain = new RecordingChain(child ->
Mono.deferContextual(context -> {
+ String id = child.getRequest().getQueryParams().getFirst("id");
+ child.getAttributes().put("private", id);
+ assertEquals("trusted", context.get("test-context"));
+ return write(child, "{\"id\":\"" + id + "\"}");
+ }).subscribeOn(Schedulers.parallel()));
+ final AgentHttpToolExecutor executor = executor(origin(), chain, 4096);
+ StepVerifier.create(Flux.range(0, 32).flatMap(id ->
executor.invoke(tool("GET"), invocation("id" + id))
+ .map(result ->
result.getAsJsonObject("body").get("id").getAsString())).collectList()
+ .contextWrite(context -> context.put("test-context",
"trusted")))
+ .assertNext(values -> assertEquals(32,
Set.copyOf(values).size())).verifyComplete();
+ assertEquals(32, chain.forks.get());
+ }
+
+ @Test
+ void cancellationPropagatesWithoutExecutingASecondAttempt() {
+ final AtomicBoolean cancelled = new AtomicBoolean();
+ RecordingChain chain = new RecordingChain(child ->
Mono.<Void>never().doOnCancel(() -> cancelled.set(true)));
+ StepVerifier.create(executor(origin(), chain,
4096).invoke(tool("GET"), invocation("A"))).thenCancel().verify();
+ assertTrue(cancelled.get());
+ assertEquals(1, chain.forks.get());
+ }
+
+ @Test
+ void rejectsAnExpiredDeadlineBeforeForking() {
+ RecordingChain chain = new RecordingChain(child -> write(child, "{}"));
+ AgentToolInvocation input = new AgentToolInvocation("same-id",
"subject", arguments("A"), "rule", 1, Instant.EPOCH);
+ StepVerifier.create(executor(origin(), chain,
4096).invoke(tool("GET"), input)).expectError(TimeoutException.class).verify();
+ assertEquals(0, chain.forks.get());
+ }
+
+ @Test
+ void refusesOversizedResponsesAndRequests() {
+ RecordingChain response = new RecordingChain(child -> write(child,
"{\"large\":\"" + "x".repeat(2000) + "\"}"));
+ StepVerifier.create(executor(origin(), response,
1024).invoke(tool("GET"),
invocation("A"))).expectError(DataBufferLimitException.class).verify();
+ RecordingChain request = new RecordingChain(child -> write(child,
"{}"));
+ AgentHttpToolExecutor bounded = new AgentHttpToolExecutor(origin(),
request, 8, 1024);
+ StepVerifier.create(bounded.invoke(tool("GET"),
invocation("A"))).expectError(DataBufferLimitException.class).verify();
+ assertEquals(0, request.forks.get());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"{\"x\":1,\"x\":2}", "{}{}", "not-json", "[[",
"{\"deep\":"})
+ void rejectsMalformedOrAmbiguousJson(final String body) {
+ RecordingChain chain = new RecordingChain(child -> write(child, body));
+ StepVerifier.create(executor(origin(), chain,
4096).invoke(tool("GET"), invocation("A"))).expectError().verify();
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {301, 400, 401, 404, 429, 500})
+ void doesNotFollowRedirectsOrReturnErrorBodies(final int status) {
+ RecordingChain chain = new RecordingChain(child -> {
+ child.getResponse().setStatusCode(HttpStatus.valueOf(status));
+ return write(child, "{\"secret\":\"upstream\"}");
+ });
+ StepVerifier.create(executor(origin(), chain,
4096).invoke(tool("GET"),
invocation("A"))).expectError(AgentToolExecutionException.class).verify();
+ assertEquals(1, chain.forks.get());
+ }
+
+ @Test
+ void rejectsAbsentRoutesSessionsAndNonJsonResults() {
+ RecordingChain absent = new RecordingChain(child -> Mono.empty());
+ StepVerifier.create(executor(origin(), absent,
4096).invoke(tool("GET"),
invocation("A"))).expectError(AgentToolExecutionException.class).verify();
+ RecordingChain session = new RecordingChain(child ->
child.getSession().then());
+ StepVerifier.create(executor(origin(), session,
4096).invoke(tool("GET"),
invocation("A"))).expectError(UnsupportedOperationException.class).verify();
+ RecordingChain html = new RecordingChain(child -> {
+
child.getResponse().getHeaders().setContentType(MediaType.TEXT_HTML);
+ return
child.getResponse().writeWith(Mono.just(child.getResponse().bufferFactory().wrap("<html/>".getBytes(StandardCharsets.UTF_8))));
+ });
+ StepVerifier.create(executor(origin(), html, 4096).invoke(tool("GET"),
invocation("A"))).expectError(AgentToolExecutionException.class).verify();
+ }
+
+ @Test
+ void customChainsMustOptIntoForking() {
+ ShenyuPluginChain unsupported = child -> write(child, "{}");
+ StepVerifier.create(executor(origin(), unsupported,
4096).invoke(tool("GET"),
invocation("A"))).expectError(UnsupportedOperationException.class).verify();
+ }
+
+ private AgentHttpToolExecutor executor(final ServerWebExchange origin,
final ShenyuPluginChain chain, final int maximum) {
+ return new AgentHttpToolExecutor(origin, chain, 4096, maximum);
+ }
+
+ private MockServerWebExchange origin() {
+ return MockServerWebExchange.from(MockServerHttpRequest.post("/agent")
+ .header("Authorization", "Bearer
must-not-forward").header("Cookie", "private=session").header("Mcp-Name",
"lookup")
+ .cookie(new HttpCookie("private",
"session")).body("{\"rpc\":\"must-not-forward\"}"));
+ }
+
+ private AgentGatewayHttpToolConfig.Tool tool(final String method) {
+ JsonObject plans = JsonParser.parseString("""
+ {"lookup":{"description":"Look up a
record","inputSchema":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]},
+
"requestConfig":{"requestTemplate":{"method":"GET","url":"/orders/status"},"argsPosition":{"id":"query"}}}}
+ """).getAsJsonObject();
+
plans.getAsJsonObject("lookup").getAsJsonObject("requestConfig").getAsJsonObject("requestTemplate").addProperty("method",
method);
+
plans.getAsJsonObject("lookup").getAsJsonObject("requestConfig").getAsJsonObject("argsPosition").addProperty("id",
"POST".equals(method) ? "body" : "query");
+ return AgentGatewayHttpToolConfig.parse(plans,
Set.of("lookup")).getTools().get("lookup");
+ }
+
+ private AgentToolInvocation invocation(final String id) {
+ return new AgentToolInvocation("same-id", "subject", arguments(id),
"rule", 1, Instant.now().plusSeconds(10));
+ }
+
+ private JsonObject arguments(final String id) {
+ JsonObject value = new JsonObject();
+ value.addProperty("id", id);
+ return value;
+ }
+
+ private static Mono<Void> write(final ServerWebExchange child, final
String body) {
+
child.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
+ return
child.getResponse().writeWith(Mono.just(child.getResponse().bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8))));
+ }
+
+ private static final class RecordingChain implements ShenyuPluginChain {
+
+ private final Function<ServerWebExchange, Mono<Void>> action;
+
+ private final AtomicInteger forks = new AtomicInteger();
+
+ private final AtomicInteger parentExecutions = new AtomicInteger();
+
+ RecordingChain(final Function<ServerWebExchange, Mono<Void>> action) {
+ this.action = action;
+ }
+
+ @Override
+ public Mono<Void> execute(final ServerWebExchange exchange) {
+ parentExecutions.incrementAndGet();
+ return Mono.error(new IllegalStateException("Original cursor must
not run"));
+ }
+
+ @Override
+ public ShenyuPluginChain fork() {
+ forks.incrementAndGet();
+ return action::apply;
+ }
+ }
+}
diff --git
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentHttpToolNamespaceTest.java
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentHttpToolNamespaceTest.java
new file mode 100644
index 0000000000..e4ea8dfc58
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentHttpToolNamespaceTest.java
@@ -0,0 +1,94 @@
+/*
+ * 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.tool;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.apache.shenyu.common.dto.AgentGatewayHttpToolConfig;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+
+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.assertThrows;
+
+/**
+ * Request-local HTTP registrations do not thaw or overwrite startup-local
metadata.
+ */
+class AgentHttpToolNamespaceTest {
+
+ @Test
+ void retainsTheOriginalFrozenMetadataWhenAddingAnHttpTool() {
+ MutableProvider provider = new MutableProvider();
+ AgentToolRegistry registry = new AgentToolRegistry(List.of(provider));
+ provider.schema.addProperty("type", "invalid-after-registration");
+ AgentToolRegistry captured =
registry.withProviders(List.of(httpProvider("http_lookup")));
+ assertEquals("object",
captured.list(Set.of("local")).get("local").get("type").getAsString());
+ assertFalse(registry.isRegistered("http_lookup"));
+ assertEquals(2, captured.listDefinitions(Set.of("local",
"http_lookup")).size());
+ }
+
+ @Test
+ void rejectsCollisionsWithoutReplacingTheOriginalProvider() {
+ AgentToolRegistry registry = new AgentToolRegistry(List.of(new
MutableProvider()));
+ assertThrows(IllegalArgumentException.class, () ->
registry.withProviders(List.of(httpProvider("local"))));
+ assertEquals(1, registry.listDefinitions(Set.of("local")).size());
+ }
+
+ private AgentHttpToolProvider httpProvider(final String name) {
+ JsonObject source = JsonParser.parseString("""
+ {"description":"HTTP
lookup","inputSchema":{"type":"object","properties":{}},
+
"requestConfig":{"requestTemplate":{"method":"GET","url":"/orders/status"},"argsPosition":{}}}
+ """).getAsJsonObject();
+ JsonObject plans = new JsonObject();
+ plans.add(name, source);
+ return new
AgentHttpToolProvider(AgentGatewayHttpToolConfig.parse(plans,
Set.of(name)).getTools().get(name), null);
+ }
+
+ private static final class MutableProvider implements AgentToolProvider {
+
+ private final JsonObject schema =
JsonParser.parseString("{\"type\":\"object\"}").getAsJsonObject();
+
+ @Override
+ public String getName() {
+ return "local";
+ }
+
+ @Override
+ public String getDescription() {
+ return "Startup local provider";
+ }
+
+ @Override
+ public JsonObject getInputSchema() {
+ return schema;
+ }
+
+ @Override
+ public void validate(final JsonObject arguments) {
+ }
+
+ @Override
+ public Mono<JsonObject> invoke(final AgentToolInvocation invocation) {
+ return Mono.just(invocation.getArguments());
+ }
+ }
+}
diff --git
a/shenyu-plugin/shenyu-plugin-api/src/main/java/org/apache/shenyu/plugin/api/ShenyuPluginChain.java
b/shenyu-plugin/shenyu-plugin-api/src/main/java/org/apache/shenyu/plugin/api/ShenyuPluginChain.java
index e322b56617..642e0fa594 100644
---
a/shenyu-plugin/shenyu-plugin-api/src/main/java/org/apache/shenyu/plugin/api/ShenyuPluginChain.java
+++
b/shenyu-plugin/shenyu-plugin-api/src/main/java/org/apache/shenyu/plugin/api/ShenyuPluginChain.java
@@ -32,4 +32,13 @@ public interface ShenyuPluginChain {
* @return {@code Mono<Void>} to indicate when request handling is complete
*/
Mono<Void> execute(ServerWebExchange exchange);
+
+ /**
+ * Create an independent cursor over this request's plugin snapshot from
the first plugin.
+ * Custom chains must explicitly support internal dispatch rather than
sharing a mutable cursor.
+ * @return independent chain
+ */
+ default ShenyuPluginChain fork() {
+ throw new UnsupportedOperationException("This plugin chain does not
support isolated internal dispatch");
+ }
}
diff --git
a/shenyu-web/src/main/java/org/apache/shenyu/web/handler/ShenyuWebHandler.java
b/shenyu-web/src/main/java/org/apache/shenyu/web/handler/ShenyuWebHandler.java
index 7a952b4878..43dc50a19a 100644
---
a/shenyu-web/src/main/java/org/apache/shenyu/web/handler/ShenyuWebHandler.java
+++
b/shenyu-web/src/main/java/org/apache/shenyu/web/handler/ShenyuWebHandler.java
@@ -302,6 +302,11 @@ public final class ShenyuWebHandler implements WebHandler,
ApplicationListener<P
this.plugins = plugins;
}
+ @Override
+ public ShenyuPluginChain fork() {
+ return new DefaultShenyuPluginChain(plugins);
+ }
+
/**
* Delegate to the next {@code WebFilter} in the chain.
*