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.
          *

Reply via email to