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 66b8fee2f4 feat(agent-gateway): record logical MCP tool call metrics
(#7522)
66b8fee2f4 is described below
commit 66b8fee2f4046990ee377221a3d39ebbd8f94547
Author: BobSong <[email protected]>
AuthorDate: Sat Oct 10 09:44:37 2026 +0800
feat(agent-gateway): record logical MCP tool call metrics (#7522)
Co-authored-by: BobSong-dev <[email protected]>
---
.../apache/shenyu/common/constant/Constants.java | 5 +
.../common/metrics/AgentMcpCallObserver.java | 52 ++++
.../shenyu-plugin-agent-gateway/README.md | 40 +++
.../gateway/protocol/AgentMcpCallObservation.java | 87 ++++++
.../gateway/protocol/AgentMcpHttpHandler.java | 13 +-
.../protocol/AgentMcpCallObservationTest.java | 346 +++++++++++++++++++++
.../shenyu/plugin/metrics/MetricsPlugin.java | 7 +
.../shenyu/plugin/metrics/constant/LabelNames.java | 6 +
.../plugin/metrics/reporter/MetricsReporter.java | 2 +
.../shenyu/plugin/metrics/MetricsPluginTest.java | 49 +++
.../metrics/reporter/MetricsReporterTest.java | 8 +-
11 files changed, 609 insertions(+), 6 deletions(-)
diff --git
a/shenyu-common/src/main/java/org/apache/shenyu/common/constant/Constants.java
b/shenyu-common/src/main/java/org/apache/shenyu/common/constant/Constants.java
index b4654e8e8a..1f58a4916a 100644
---
a/shenyu-common/src/main/java/org/apache/shenyu/common/constant/Constants.java
+++
b/shenyu-common/src/main/java/org/apache/shenyu/common/constant/Constants.java
@@ -896,6 +896,11 @@ public interface Constants {
*/
String METRICS_HYSTRIX = "metricsHystrix";
+ /**
+ * Optional server-installed MCP terminal observation callback.
+ */
+ String METRICS_AGENT_MCP_CALL = "metricsAgentMcpCall";
+
/**
* The constant shenyu namespace id.
*/
diff --git
a/shenyu-common/src/main/java/org/apache/shenyu/common/metrics/AgentMcpCallObserver.java
b/shenyu-common/src/main/java/org/apache/shenyu/common/metrics/AgentMcpCallObserver.java
new file mode 100644
index 0000000000..3c4efb1b80
--- /dev/null
+++
b/shenyu-common/src/main/java/org/apache/shenyu/common/metrics/AgentMcpCallObserver.java
@@ -0,0 +1,52 @@
+/*
+ * 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.metrics;
+
+/**
+ * Server-side terminal observation for a parsed, authenticated MCP tools/call.
+ * Implementations must be nonblocking and must not retain request data.
+ */
+@FunctionalInterface
+public interface AgentMcpCallObserver {
+
+ /**
+ * Record one logical call after execution and response writing terminate.
+ *
+ * @param outcome bounded terminal classification
+ * @param elapsedMillis monotonic elapsed time since the validated call
was parsed
+ */
+ void record(Outcome outcome, long elapsedMillis);
+
+ /**
+ * Bounded labels; no tool names, principals or client-controlled
identifiers.
+ */
+ enum Outcome {
+ /** Successful tool result and completed response write. */
+ SUCCESS,
+ /** Completed MCP result with isError=true. */
+ TOOL_ERROR,
+ /** Protocol or authorization rejection after a valid call was parsed.
*/
+ REJECTED,
+ /** Internal, encoding or response-writing failure. */
+ SERVER_ERROR,
+ /** The gateway request deadline expired. */
+ TIMEOUT,
+ /** The downstream subscription was cancelled. */
+ CANCELLED
+ }
+}
diff --git a/shenyu-plugin/shenyu-plugin-agent-gateway/README.md
b/shenyu-plugin/shenyu-plugin-agent-gateway/README.md
index ecc56fbbdb..38e63a83a0 100644
--- a/shenyu-plugin/shenyu-plugin-agent-gateway/README.md
+++ b/shenyu-plugin/shenyu-plugin-agent-gateway/README.md
@@ -262,6 +262,46 @@ the request body, credentials, response content, or
mutable plugin-chain state.
Each subscription gets its own context. The same exchange should not be
subscribed concurrently, because exchange attributes are shared by that
request.
+## Tool call metrics
+
+When the existing ShenYu Metrics plugin runs before Agent Gateway, it installs
+a server-side terminal callback. No new exporter, endpoint, dependency or Admin
+configuration is required. Without that plugin, calls behave as before and no
+MCP statistics are recorded.
+
+`shenyu_agent_mcp_calls_total{outcome}` counts one logical call per
subscription,
+starting only after authentication and valid `tools/call` parsing. It includes
+subsequent authorization/argument rejection, not just provider invocations.
+Discovery, listing, malformed input and pre-authentication refusals remain in
+the existing HTTP metrics; they are not tool calls. Retries and upstream
attempts
+are not introduced or counted separately.
+
+The six fixed outcomes are `success`, `tool_error` (including invalid tool
+arguments reported as MCP `isError=true`), `rejected`, `server_error`,
`timeout`
+and `cancelled`. A result computed by a tool is not a successful call until its
+response write completes. Encoding/write failures count as `server_error`;
+deadline expiry and downstream cancellation are distinct. Each subscription
+records at most one terminal event, even with repeated RPC ids or late signals.
+
+`shenyu_agent_mcp_call_latency_millis{outcome}` uses monotonic elapsed time
from
+validated parsing through the terminal response write/error/cancellation. Its
+`_sum` and `_count` can be used for mean latency in milliseconds. This first
+version reuses the existing histogram registration/buckets; it does not claim
+a new percentile policy. No subject, tool name, RPC/request id, configuration
+version, arguments or response body is a metric label.
+
+For a time window, the logical-call failure ratio is
+`(tool_error + server_error + timeout) / (success + tool_error + server_error
+ timeout)`.
+Use rates/increases of the terminal counter, not cumulative process totals;
+report authorization refusals and cancellations separately. An empty
denominator
+means no applicable observations, not a zero failure rate. Callback failures
are
+best-effort observation failures and do not change a response or trigger
retries.
+
+No token or cost metric is emitted: MCP does not supply a trusted model-usage
+source here, and missing usage must not be represented as zero or estimated
from
+tool-call count/result length. Remote-catalog-specific labels, per-tool
metrics,
+in-flight gauges and persistent reporting remain outside this first version.
+
## Scope
This module provides LLM request correlation and a tools-only MCP entry with
diff --git
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpCallObservation.java
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpCallObservation.java
new file mode 100644
index 0000000000..2e47c4354d
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpCallObservation.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.agent.gateway.protocol;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.apache.shenyu.common.metrics.AgentMcpCallObserver;
+import org.apache.shenyu.common.metrics.AgentMcpCallObserver.Outcome;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.SignalType;
+
+import java.util.Objects;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * One subscription's bounded observation state, independent of RPC ids and
sessions.
+ */
+final class AgentMcpCallObservation {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(AgentMcpCallObservation.class);
+
+ private final AgentMcpCallObserver observer;
+
+ private final AtomicBoolean recorded = new AtomicBoolean();
+
+ private volatile long started;
+
+ private volatile boolean call;
+
+ private volatile Outcome outcome = Outcome.SERVER_ERROR;
+
+ AgentMcpCallObservation(final Object observer) {
+ this.observer = observer instanceof AgentMcpCallObserver ?
(AgentMcpCallObserver) observer : null;
+ }
+
+ void parsed(final AgentMcpRequest request) {
+ if (Objects.nonNull(observer) &&
"tools/call".equals(request.getMethod())) {
+ started = System.nanoTime();
+ call = true;
+ }
+ }
+
+ void response(final ObjectNode response) {
+ outcome = response.path("result").path("isError").asBoolean(false) ?
Outcome.TOOL_ERROR : Outcome.SUCCESS;
+ }
+
+ void failure(final Throwable error) {
+ if (error instanceof TimeoutException) {
+ outcome = Outcome.TIMEOUT;
+ } else if (error instanceof AgentMcpProtocolException &&
((AgentMcpProtocolException) error).getHttpStatus() < 500) {
+ outcome = Outcome.REJECTED;
+ } else {
+ outcome = Outcome.SERVER_ERROR;
+ }
+ }
+
+ void finish(final SignalType signal) {
+ if (!call || !recorded.compareAndSet(false, true)) {
+ return;
+ }
+ Outcome terminal = signal == SignalType.CANCEL ? Outcome.CANCELLED :
outcome;
+ long millis = TimeUnit.NANOSECONDS.toMillis(Math.max(0,
System.nanoTime() - started));
+ try {
+ observer.record(terminal, millis);
+ } catch (RuntimeException error) {
+ // Observability must not fail a call or log potentially sensitive
callback errors.
+ LOG.debug("MCP call statistics callback failed");
+ }
+ }
+}
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 610fcebd31..b0edc084dc 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
@@ -20,6 +20,7 @@ package org.apache.shenyu.plugin.agent.gateway.protocol;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.apache.shenyu.common.constant.Constants;
import org.apache.shenyu.common.dto.AgentGatewayMcpConfig;
import org.apache.shenyu.plugin.agent.gateway.AgentTrafficContext;
import
org.apache.shenyu.plugin.agent.gateway.security.AgentMcpSecurityResolver;
@@ -79,6 +80,7 @@ public final class AgentMcpHttpHandler {
final AgentTrafficContext traffic, final long
configurationVersion) {
return Mono.defer(() -> {
final Instant deadline =
Instant.now().plusMillis(config.getTimeoutMs());
+ final AgentMcpCallObservation observation = new
AgentMcpCallObservation(exchange.getAttribute(Constants.METRICS_AGENT_MCP_CALL));
AtomicReference<JsonNode> rpcId = new AtomicReference<>();
return Mono.fromRunnable(() -> preflight(exchange, config))
.then(Mono.defer(() -> securityResolver.resolve(exchange))
@@ -86,12 +88,19 @@ public final class AgentMcpHttpHandler {
.flatMap(identity -> read(exchange, config).map(body ->
parser.parse(body, exchange.getRequest().getHeaders(),
config.getMaxRequestBytes()))
.flatMap(request -> {
rpcId.set(request.getId());
+ observation.parsed(request);
return dispatcher.dispatch(request, () -> new
AgentMcpExecutionContext(traffic.getRequestId(), identity.getSubject(),
traffic.getRuleId(),
configurationVersion, config.getAllowedTools(), identity.getToolGrants(),
deadline));
}))
- .flatMap(response -> write(exchange, response, config,
"sse".equals(config.getResponseMode()), 200))
+ .flatMap(response -> {
+ observation.response(response);
+ return write(exchange, response, config,
"sse".equals(config.getResponseMode()), 200);
+ })
.timeout(Duration.ofMillis(config.getTimeoutMs()))
- .onErrorResume(error -> handleFailure(exchange, config,
rpcId.get(), error));
+ .doOnError(observation::failure)
+ .onErrorResume(error -> handleFailure(exchange, config,
rpcId.get(), error))
+ .doOnError(observation::failure)
+ .doFinally(observation::finish);
});
}
diff --git
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpCallObservationTest.java
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpCallObservationTest.java
new file mode 100644
index 0000000000..dbde556e0f
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/protocol/AgentMcpCallObservationTest.java
@@ -0,0 +1,346 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.agent.gateway.protocol;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.apache.shenyu.common.constant.Constants;
+import org.apache.shenyu.common.dto.AgentGatewayMcpConfig;
+import org.apache.shenyu.common.metrics.AgentMcpCallObserver;
+import org.apache.shenyu.common.metrics.AgentMcpCallObserver.Outcome;
+import org.apache.shenyu.plugin.agent.gateway.AgentTrafficContext;
+import org.apache.shenyu.plugin.agent.gateway.security.AgentMcpIdentity;
+import org.apache.shenyu.plugin.agent.gateway.tool.AgentToolExecutionException;
+import org.apache.shenyu.plugin.agent.gateway.tool.AgentToolProvider;
+import org.apache.shenyu.plugin.agent.gateway.tool.AgentToolRegistry;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.core.io.buffer.DataBufferUtils;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.SignalType;
+import reactor.core.publisher.Sinks;
+import reactor.core.scheduler.Schedulers;
+import reactor.test.StepVerifier;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.awaitility.Awaitility.await;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Logical call observation through execution, encoding, writing and
cancellation.
+ */
+class AgentMcpCallObservationTest {
+
+ private final AgentTrafficContext traffic = new
AgentTrafficContext("private-id", "mcp", "selector", "rule");
+
+ @ParameterizedTest
+ @CsvSource({"json,SUCCESS", "sse,SUCCESS", "json,TOOL_ERROR",
"sse,TOOL_ERROR"})
+ void recordsBusinessOutcomeNotOnlyHttpStatus(final String mode, final
Outcome expected) {
+ AgentToolProvider tool = tool(expected == Outcome.TOOL_ERROR ?
Mono.error(new AgentToolExecutionException("private-diagnostic")) :
Mono.just(new JsonObject()));
+ Recording observer = new Recording();
+ MockServerWebExchange exchange = exchange("tools/call", observer);
+ StepVerifier.create(handler(tool, Set.of("read")).handle(exchange,
config(mode, 30000), traffic, 1)).verifyComplete();
+ observer.assertOnly(expected);
+ assertEquals(200, exchange.getResponse().getStatusCode().value());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"json", "sse"})
+ void recordsRejectedCallWithoutInvokingProvider(final String mode) {
+ AtomicInteger invocations = new AtomicInteger();
+ AgentToolProvider tool = tool(Mono.defer(() -> {
+ invocations.incrementAndGet();
+ return Mono.just(new JsonObject());
+ }));
+ Recording observer = new Recording();
+ MockServerWebExchange exchange = exchange("tools/call", observer);
+ StepVerifier.create(handler(tool, Set.of()).handle(exchange,
config(mode, 30000), traffic, 1)).verifyComplete();
+ observer.assertOnly(Outcome.REJECTED);
+ assertEquals(0, invocations.get());
+ assertEquals(403, exchange.getResponse().getStatusCode().value());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"json", "sse"})
+ void recordsInternalErrorNotRecoveredHttpCompletion(final String mode) {
+ Recording observer = new Recording();
+ MockServerWebExchange exchange = exchange("tools/call", observer);
+ StepVerifier.create(handler(tool(Mono.error(new
IllegalStateException("private-error"))), Set.of("read"))
+ .handle(exchange, config(mode, 30000), traffic,
1)).verifyComplete();
+ observer.assertOnly(Outcome.SERVER_ERROR);
+ assertEquals(500, exchange.getResponse().getStatusCode().value());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"json", "sse"})
+ void recordsDeadlineOnceInsteadOfProviderCancellation(final String mode) {
+ Recording observer = new Recording();
+ AtomicBoolean cancelled = new AtomicBoolean();
+ MockServerWebExchange exchange = exchange("tools/call", observer);
+ AgentMcpHttpHandler handler =
handler(tool(Mono.<JsonObject>never().doOnCancel(() -> cancelled.set(true))),
Set.of("read"));
+ StepVerifier.withVirtualTime(() -> handler.handle(exchange,
config(mode, 100), traffic, 1))
+ .thenAwait(Duration.ofMillis(101)).verifyComplete();
+ observer.assertOnly(Outcome.TIMEOUT);
+ assertTrue(cancelled.get());
+ assertEquals(504, exchange.getResponse().getStatusCode().value());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"json", "sse"})
+ void recordsDownstreamCancellationDuringExecution(final String mode) {
+ Recording observer = new Recording();
+ AtomicBoolean started = new AtomicBoolean();
+ AtomicBoolean cancelled = new AtomicBoolean();
+ MockServerWebExchange exchange = exchange("tools/call", observer);
+ AgentMcpHttpHandler handler =
handler(tool(Mono.<JsonObject>never().doOnSubscribe(subscription ->
started.set(true))
+ .doOnCancel(() -> cancelled.set(true))), Set.of("read"));
+ StepVerifier.create(handler.handle(exchange, config(mode, 30000),
traffic, 1)).then(() -> assertTrue(started.get())).thenCancel().verify();
+ observer.assertOnly(Outcome.CANCELLED);
+ assertTrue(cancelled.get());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"json", "sse"})
+ void doesNotCountComputedResultAsSuccessDuringStalledWriting(final String
mode) {
+ Recording observer = new Recording();
+ MockServerWebExchange exchange = exchange("tools/call", observer);
+ exchange.getResponse().setWriteHandler(buffers ->
buffers.doOnNext(DataBufferUtils::release).then(Mono.never()));
+ StepVerifier.create(handler(tool(Mono.just(new JsonObject())),
Set.of("read")).handle(exchange, config(mode, 30000), traffic, 1))
+ .then(() ->
assertTrue(observer.outcomes.isEmpty())).thenCancel().verify();
+ observer.assertOnly(Outcome.CANCELLED);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"json", "sse"})
+ void recordsResponseWriteErrorNotComputedSuccess(final String mode) {
+ Recording observer = new Recording();
+ MockServerWebExchange exchange = exchange("tools/call", observer);
+ exchange.getResponse().setWriteHandler(buffers ->
buffers.doOnNext(DataBufferUtils::release)
+ .then(Mono.error(new
IllegalStateException("private-write-error"))));
+ StepVerifier.create(handler(tool(Mono.just(new JsonObject())),
Set.of("read")).handle(exchange, config(mode, 30000), traffic, 1))
+ .expectError(IllegalStateException.class).verify();
+ observer.assertOnly(Outcome.SERVER_ERROR);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"json", "sse"})
+ void recordsResponseLimitFailureWithoutLeakingSuccess(final String mode) {
+ JsonObject result = new JsonObject();
+ result.addProperty("large", "x".repeat(2000));
+ Recording observer = new Recording();
+ MockServerWebExchange exchange = exchange("tools/call", observer);
+ JsonObject settings =
JsonParser.parseString("{\"allowedTools\":[\"read\"],\"maxResponseBytes\":1024,\"responseMode\":\""
+ mode + "\"}").getAsJsonObject();
+ StepVerifier.create(handler(tool(Mono.just(result)),
Set.of("read")).handle(exchange, AgentGatewayMcpConfig.parse(settings, true),
traffic, 1)).verifyComplete();
+ observer.assertOnly(Outcome.SERVER_ERROR);
+ assertEquals(500, exchange.getResponse().getStatusCode().value());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"json", "sse"})
+ void timeoutWhileWritingIsNotSuccessfulToolCompletion(final String mode) {
+ Recording observer = new Recording();
+ MockServerWebExchange exchange = exchange("tools/call", observer);
+ exchange.getResponse().setWriteHandler(buffers ->
buffers.doOnNext(DataBufferUtils::release).then(Mono.never()));
+ AgentMcpHttpHandler handler = handler(tool(Mono.just(new
JsonObject())), Set.of("read"));
+ StepVerifier.withVirtualTime(() -> handler.handle(exchange,
config(mode, 100), traffic, 1))
+
.thenAwait(Duration.ofMillis(101)).expectError(TimeoutException.class).verify();
+ observer.assertOnly(Outcome.TIMEOUT);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"server/discover", "tools/list"})
+ void excludesDiscoveryAndListing(final String method) {
+ Recording observer = new Recording();
+ StepVerifier.create(handler(tool(Mono.just(new JsonObject())),
Set.of("read"))
+ .handle(exchange(method, observer), config("json", 30000),
traffic, 1)).verifyComplete();
+ assertTrue(observer.outcomes.isEmpty());
+ }
+
+ @Test
+ void excludesUnparsedAndUnauthenticatedRequests() {
+ Recording observer = new Recording();
+ MockServerWebExchange malformed =
MockServerWebExchange.from(MockServerHttpRequest.post("/agent")
+
.contentType(MediaType.APPLICATION_JSON).header(HttpHeaders.ACCEPT,
"application/json, text/event-stream").body("{}"));
+ malformed.getAttributes().put(Constants.METRICS_AGENT_MCP_CALL,
observer);
+ StepVerifier.create(handler(tool(Mono.just(new JsonObject())),
Set.of("read"))
+ .handle(malformed, config("json", 30000), traffic,
1)).verifyComplete();
+ AgentMcpHttpHandler unauthenticated = new AgentMcpHttpHandler(new
AgentMcpDispatcher(new AgentToolRegistry(List.of()), "test", "1"), ignored ->
Mono.empty());
+ StepVerifier.create(unauthenticated.handle(exchange("tools/call",
observer), config("json", 30000), traffic, 1)).verifyComplete();
+ assertTrue(observer.outcomes.isEmpty());
+ }
+
+ @Test
+ void callbackFailuresDoNotChangeSuccessfulResponse() {
+ AtomicInteger records = new AtomicInteger();
+ AgentMcpCallObserver observer = (outcome, millis) -> {
+ records.incrementAndGet();
+ throw new IllegalStateException("private-observer-error");
+ };
+ MockServerWebExchange exchange = exchange("tools/call", observer);
+ StepVerifier.create(handler(tool(Mono.just(new JsonObject())),
Set.of("read"))
+ .handle(exchange, config("json", 30000), traffic,
1)).verifyComplete();
+ assertEquals(1, records.get());
+ assertEquals(200, exchange.getResponse().getStatusCode().value());
+ }
+
+ @Test
+ void concurrentSameRpcIdsHaveIndependentTerminalRecords() {
+ Recording observer = new Recording();
+ AgentMcpHttpHandler handler = handler(tool(Mono.just(new
JsonObject()).publishOn(Schedulers.parallel())), Set.of("read"));
+ StepVerifier.create(Flux.range(0, 32).flatMap(index ->
handler.handle(exchange("tools/call", observer), config("json", 30000),
traffic, index), 32))
+ .verifyComplete();
+ await().atMost(Duration.ofSeconds(3)).until(() ->
observer.outcomes.size() == 32);
+ assertEquals(32, observer.outcomes.size());
+ assertTrue(observer.outcomes.stream().allMatch(outcome -> outcome ==
Outcome.SUCCESS));
+ }
+
+ @Test
+ void aFreshSubscriptionDoesNotReuseThePreviousTerminalRecord() {
+ Recording observer = new Recording();
+ AgentMcpHttpHandler handler = handler(tool(Mono.just(new
JsonObject())), Set.of("read"));
+ Mono<Void> call = Mono.defer(() ->
handler.handle(exchange("tools/call", observer), config("json", 30000),
traffic, 1));
+ StepVerifier.create(call).verifyComplete();
+ StepVerifier.create(call).verifyComplete();
+ assertEquals(List.of(Outcome.SUCCESS, Outcome.SUCCESS),
List.copyOf(observer.outcomes));
+ }
+
+ @Test
+ void cancellingOneCallDoesNotFinalizeAnotherWithTheSameRpcId() {
+ Recording cancelled = new Recording();
+ Recording successful = new Recording();
+ AtomicBoolean started = new AtomicBoolean();
+ Sinks.One<JsonObject> pending = Sinks.one();
+ StepVerifier.create(handler(tool(pending.asMono()), Set.of("read"))
+ .handle(exchange("tools/call", successful), config("json",
30000), traffic, 1))
+ .then(() -> {
+
StepVerifier.create(handler(tool(Mono.<JsonObject>never().doOnSubscribe(subscription
-> started.set(true))), Set.of("read"))
+ .handle(exchange("tools/call", cancelled),
config("json", 30000), traffic, 1))
+ .then(() ->
assertTrue(started.get())).thenCancel().verify();
+ cancelled.assertOnly(Outcome.CANCELLED);
+ assertTrue(successful.outcomes.isEmpty());
+ assertEquals(Sinks.EmitResult.OK, pending.tryEmitValue(new
JsonObject()));
+ }).verifyComplete();
+ successful.assertOnly(Outcome.SUCCESS);
+ }
+
+ @Test
+ void lateSignalsCannotRecordATerminalTwice() {
+ Recording observer = new Recording();
+ AgentMcpCallObservation observation = new
AgentMcpCallObservation(observer);
+ AgentMcpRequest request = new
AgentMcpRequestParser().parse(body("tools/call").getBytes(StandardCharsets.UTF_8),
+ exchange("tools/call", observer).getRequest().getHeaders(),
262144);
+ observation.parsed(request);
+ observation.response(new ObjectMapper().createObjectNode());
+ observation.finish(SignalType.CANCEL);
+ observation.finish(SignalType.ON_COMPLETE);
+ observation.failure(new IllegalStateException("late-error"));
+ observation.finish(SignalType.ON_ERROR);
+ observer.assertOnly(Outcome.CANCELLED);
+ }
+
+ @Test
+ void absentOrWrongTypeCallbackDoesNotRequireMetricsPlugin() {
+ MockServerWebExchange exchange = exchange("tools/call", null);
+ exchange.getAttributes().put(Constants.METRICS_AGENT_MCP_CALL,
"unusable-callback");
+ StepVerifier.create(handler(tool(Mono.just(new JsonObject())),
Set.of("read"))
+ .handle(exchange, config("json", 30000), traffic,
1)).verifyComplete();
+ assertEquals(200, exchange.getResponse().getStatusCode().value());
+ }
+
+ @Test
+ void cancellationBeforeParsingIsNotAToolCall() {
+ Recording observer = new Recording();
+ AgentMcpHttpHandler handler = handler(tool(Mono.never()),
Set.of("read"));
+ StepVerifier.create(handler.handle(exchange("tools/call", observer),
config("json", 30000), traffic, 1)).thenCancel().verify();
+ assertTrue(observer.outcomes.isEmpty());
+ }
+
+ private AgentMcpHttpHandler handler(final AgentToolProvider tool, final
Set<String> grants) {
+ return new AgentMcpHttpHandler(new AgentMcpDispatcher(new
AgentToolRegistry(List.of(tool)), "test", "1"),
+ ignored -> Mono.just(new AgentMcpIdentity("private-subject",
grants)));
+ }
+
+ private AgentToolProvider tool(final Mono<JsonObject> action) {
+ AgentToolProvider tool = mock(AgentToolProvider.class);
+ when(tool.getName()).thenReturn("read");
+ when(tool.getDescription()).thenReturn("Read test data");
+
when(tool.getInputSchema()).thenReturn(JsonParser.parseString("{\"type\":\"object\"}").getAsJsonObject());
+ when(tool.getRequiredClientCapabilities()).thenReturn(new
JsonObject());
+ when(tool.invoke(any())).thenReturn(action);
+ return tool;
+ }
+
+ private AgentGatewayMcpConfig config(final String mode, final int timeout)
{
+ return
AgentGatewayMcpConfig.parse(JsonParser.parseString("{\"allowedTools\":[\"read\"],\"responseMode\":\""
+ mode
+ + "\",\"timeoutMs\":" + timeout + "}").getAsJsonObject(),
true);
+ }
+
+ private MockServerWebExchange exchange(final String method, final
AgentMcpCallObserver observer) {
+ MockServerHttpRequest.BodyBuilder builder =
MockServerHttpRequest.post("/agent").contentType(MediaType.APPLICATION_JSON)
+ .header(HttpHeaders.ACCEPT, "application/json,
text/event-stream").header("MCP-Protocol-Version",
AgentMcpRequestParser.VERSION).header("Mcp-Method", method);
+ if ("tools/call".equals(method)) {
+ builder.header("Mcp-Name", "read");
+ }
+ MockServerWebExchange exchange =
MockServerWebExchange.from(builder.body(body(method)));
+ if (Objects.nonNull(observer)) {
+ exchange.getAttributes().put(Constants.METRICS_AGENT_MCP_CALL,
observer);
+ }
+ return exchange;
+ }
+
+ private String body(final String method) {
+ return "{\"jsonrpc\":\"2.0\",\"id\":\"same-id\",\"method\":\"" +
method + "\",\"params\":{\"name\":\"read\",\"_meta\":{"
+ +
"\"io.modelcontextprotocol/protocolVersion\":\"2026-07-28\",\"io.modelcontextprotocol/clientCapabilities\":{}}}}";
+ }
+
+ private static final class Recording implements AgentMcpCallObserver {
+
+ private final ConcurrentLinkedQueue<Outcome> outcomes = new
ConcurrentLinkedQueue<>();
+
+ @Override
+ public void record(final Outcome outcome, final long millis) {
+ assertTrue(millis >= 0);
+ outcomes.add(outcome);
+ }
+
+ private void assertOnly(final Outcome outcome) {
+ assertEquals(List.of(outcome), List.copyOf(outcomes));
+ }
+ }
+}
diff --git
a/shenyu-plugin/shenyu-plugin-metrics/src/main/java/org/apache/shenyu/plugin/metrics/MetricsPlugin.java
b/shenyu-plugin/shenyu-plugin-metrics/src/main/java/org/apache/shenyu/plugin/metrics/MetricsPlugin.java
index 66c2f9f850..a6527250a6 100644
---
a/shenyu-plugin/shenyu-plugin-metrics/src/main/java/org/apache/shenyu/plugin/metrics/MetricsPlugin.java
+++
b/shenyu-plugin/shenyu-plugin-metrics/src/main/java/org/apache/shenyu/plugin/metrics/MetricsPlugin.java
@@ -19,6 +19,7 @@ package org.apache.shenyu.plugin.metrics;
import org.apache.shenyu.common.constant.Constants;
import org.apache.shenyu.common.enums.PluginEnum;
+import org.apache.shenyu.common.metrics.AgentMcpCallObserver;
import org.apache.shenyu.common.utils.DateUtils;
import org.apache.shenyu.plugin.api.ShenyuPlugin;
import org.apache.shenyu.plugin.api.ShenyuPluginChain;
@@ -33,6 +34,7 @@ import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.Optional;
+import java.util.Locale;
import java.util.function.Consumer;
/**
@@ -56,6 +58,11 @@ public class MetricsPlugin implements ShenyuPlugin {
}
private void setMetricsCallbacks(final ServerWebExchange exchange) {
+ exchange.getAttributes().put(Constants.METRICS_AGENT_MCP_CALL,
(AgentMcpCallObserver) (outcome, millis) -> {
+ String[] labels = {outcome.name().toLowerCase(Locale.ROOT)};
+ MetricsReporter.counterIncrement(LabelNames.AGENT_MCP_CALLS_TOTAL,
labels);
+ MetricsReporter.recordTime(LabelNames.AGENT_MCP_CALL_LATENCY,
labels, millis);
+ });
exchange.getAttributes().put(Constants.METRICS_SENTINEL,
(Consumer<HttpStatus>) status -> {
if (Objects.equals(HttpStatus.TOO_MANY_REQUESTS, status)) {
MetricsReporter.counterIncrement(LabelNames.SENTINEL_REQUEST_RESTRICT_TOTAL);
diff --git
a/shenyu-plugin/shenyu-plugin-metrics/src/main/java/org/apache/shenyu/plugin/metrics/constant/LabelNames.java
b/shenyu-plugin/shenyu-plugin-metrics/src/main/java/org/apache/shenyu/plugin/metrics/constant/LabelNames.java
index 7db4b6d8e2..eccada895e 100644
---
a/shenyu-plugin/shenyu-plugin-metrics/src/main/java/org/apache/shenyu/plugin/metrics/constant/LabelNames.java
+++
b/shenyu-plugin/shenyu-plugin-metrics/src/main/java/org/apache/shenyu/plugin/metrics/constant/LabelNames.java
@@ -21,6 +21,12 @@ package org.apache.shenyu.plugin.metrics.constant;
* The Label names.
*/
public final class LabelNames {
+
+ /** Completed authenticated, parsed MCP logical calls, labelled only by
outcome. */
+ public static final String AGENT_MCP_CALLS_TOTAL =
"shenyu_agent_mcp_calls_total";
+
+ /** Time from validated MCP call parsing through terminal response
writing. */
+ public static final String AGENT_MCP_CALL_LATENCY =
"shenyu_agent_mcp_call_latency_millis";
/**
* The constant REQUEST_TOTAL.
diff --git
a/shenyu-plugin/shenyu-plugin-metrics/src/main/java/org/apache/shenyu/plugin/metrics/reporter/MetricsReporter.java
b/shenyu-plugin/shenyu-plugin-metrics/src/main/java/org/apache/shenyu/plugin/metrics/reporter/MetricsReporter.java
index 38096fa29b..5e4b035340 100644
---
a/shenyu-plugin/shenyu-plugin-metrics/src/main/java/org/apache/shenyu/plugin/metrics/reporter/MetricsReporter.java
+++
b/shenyu-plugin/shenyu-plugin-metrics/src/main/java/org/apache/shenyu/plugin/metrics/reporter/MetricsReporter.java
@@ -43,6 +43,8 @@ public final class MetricsReporter {
MetricsReporter.registerCounter(LabelNames.REQUEST_TYPE_TOTAL, new
String[]{"type"}, "shenyu http request type total count");
MetricsReporter.registerCounter(LabelNames.REQUEST_THROW_TOTAL,
"shenyu request error total count");
MetricsReporter.registerHistogram(LabelNames.EXECUTE_LATENCY_NAME,
"the shenyu executor latency millis");
+ MetricsReporter.registerCounter(LabelNames.AGENT_MCP_CALLS_TOTAL, new
String[]{"outcome"}, "Terminal authenticated parsed MCP logical calls");
+ MetricsReporter.registerHistogram(LabelNames.AGENT_MCP_CALL_LATENCY,
new String[]{"outcome"}, "MCP logical call terminal latency millis");
MetricsReporter.registerCounter(LabelNames.SENTINEL_REQUEST_RESTRICT_TOTAL,
"shenyu sentinel request restrict total count");
MetricsReporter.registerCounter(LabelNames.SENTINEL_REQUEST_CIRCUITBREAKER_TOTAL,
"shenyu sentinel circuitbreaker request total count");
MetricsReporter.registerCounter(LabelNames.RESILIENCE4J_REQUEST_RESTRICT_TOTAL,
"shenyu resilience4j request restrict total count");
diff --git
a/shenyu-plugin/shenyu-plugin-metrics/src/test/java/org/apache/shenyu/plugin/metrics/MetricsPluginTest.java
b/shenyu-plugin/shenyu-plugin-metrics/src/test/java/org/apache/shenyu/plugin/metrics/MetricsPluginTest.java
index 1b07081afb..5465b9a1a8 100644
---
a/shenyu-plugin/shenyu-plugin-metrics/src/test/java/org/apache/shenyu/plugin/metrics/MetricsPluginTest.java
+++
b/shenyu-plugin/shenyu-plugin-metrics/src/test/java/org/apache/shenyu/plugin/metrics/MetricsPluginTest.java
@@ -17,15 +17,18 @@
package org.apache.shenyu.plugin.metrics;
+import io.prometheus.client.CollectorRegistry;
import org.apache.shenyu.common.constant.Constants;
import org.apache.shenyu.common.enums.PluginEnum;
import org.apache.shenyu.common.enums.RpcTypeEnum;
+import org.apache.shenyu.common.metrics.AgentMcpCallObserver;
import org.apache.shenyu.plugin.api.RemoteAddressResolver;
import org.apache.shenyu.plugin.api.ShenyuPluginChain;
import org.apache.shenyu.plugin.api.context.ShenyuContext;
import org.apache.shenyu.plugin.api.utils.SpringBeanUtils;
import org.apache.shenyu.plugin.metrics.constant.LabelNames;
import org.apache.shenyu.plugin.metrics.reporter.MetricsReporter;
+import org.apache.shenyu.plugin.metrics.prometheus.PrometheusMetricsRegister;
import org.apache.shenyu.plugin.metrics.spi.MetricsRegister;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
@@ -40,6 +43,7 @@ import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.net.InetSocketAddress;
+import java.util.Locale;
/**
* The Test Case For MetricsPlugin.
@@ -111,6 +115,51 @@ public class MetricsPluginTest {
}
}
+ @Test
+ public void testMcpCallbackUsesOnlyBoundedOutcomes() {
+ MetricsRegister metricsRegister = Mockito.mock(MetricsRegister.class);
+ MetricsReporter.register(metricsRegister);
+ try {
+
Mockito.when(chain.execute(ArgumentMatchers.any())).thenReturn(Mono.empty());
+ StepVerifier.create(metricsPlugin.execute(exchange,
chain)).verifyComplete();
+ AgentMcpCallObserver observer =
exchange.getAttribute(Constants.METRICS_AGENT_MCP_CALL);
+ Assertions.assertNotNull(observer);
+ for (AgentMcpCallObserver.Outcome outcome :
AgentMcpCallObserver.Outcome.values()) {
+ observer.record(outcome, 42);
+ String[] labels = {outcome.name().toLowerCase(Locale.ROOT)};
+
Mockito.verify(metricsRegister).counterIncrement(LabelNames.AGENT_MCP_CALLS_TOTAL,
labels, 1L);
+
Mockito.verify(metricsRegister).recordTime(LabelNames.AGENT_MCP_CALL_LATENCY,
labels, 42);
+ }
+ } finally {
+ MetricsReporter.clean();
+ }
+ }
+
+ @Test
+ public void testMcpTerminalMetricsAreExposedByExistingPrometheusRegistry()
{
+ MetricsReporter.clean();
+ CollectorRegistry.defaultRegistry.clear();
+ MetricsReporter.register(new PrometheusMetricsRegister());
+ try {
+ ShenyuContext shenyuContext =
exchange.getAttribute(Constants.CONTEXT);
+
Mockito.when(shenyuContext.getRpcType()).thenReturn(RpcTypeEnum.HTTP.getName());
+
Mockito.when(chain.execute(ArgumentMatchers.any())).thenReturn(Mono.empty());
+ StepVerifier.create(metricsPlugin.execute(exchange,
chain)).verifyComplete();
+ AgentMcpCallObserver observer =
exchange.getAttribute(Constants.METRICS_AGENT_MCP_CALL);
+ Assertions.assertNotNull(observer);
+ for (AgentMcpCallObserver.Outcome outcome :
AgentMcpCallObserver.Outcome.values()) {
+ observer.record(outcome, 42);
+ String[] labels = {outcome.name().toLowerCase(Locale.ROOT)};
+ Assertions.assertEquals(1.0,
CollectorRegistry.defaultRegistry.getSampleValue(LabelNames.AGENT_MCP_CALLS_TOTAL,
new String[]{"outcome"}, labels));
+ Assertions.assertEquals(1.0,
CollectorRegistry.defaultRegistry.getSampleValue(LabelNames.AGENT_MCP_CALL_LATENCY
+ "_count", new String[]{"outcome"}, labels));
+ Assertions.assertEquals(42.0,
CollectorRegistry.defaultRegistry.getSampleValue(LabelNames.AGENT_MCP_CALL_LATENCY
+ "_sum", new String[]{"outcome"}, labels));
+ }
+ } finally {
+ MetricsReporter.clean();
+ CollectorRegistry.defaultRegistry.clear();
+ }
+ }
+
private ServerWebExchange createExchange(final String path, final
ShenyuContext shenyuContext) {
ServerWebExchange result =
MockServerWebExchange.from(MockServerHttpRequest.get("http://localhost" +
path).build());
result.getAttributes().put(Constants.CONTEXT, shenyuContext);
diff --git
a/shenyu-plugin/shenyu-plugin-metrics/src/test/java/org/apache/shenyu/plugin/metrics/reporter/MetricsReporterTest.java
b/shenyu-plugin/shenyu-plugin-metrics/src/test/java/org/apache/shenyu/plugin/metrics/reporter/MetricsReporterTest.java
index 4bb6f35dec..023635a2d9 100644
---
a/shenyu-plugin/shenyu-plugin-metrics/src/test/java/org/apache/shenyu/plugin/metrics/reporter/MetricsReporterTest.java
+++
b/shenyu-plugin/shenyu-plugin-metrics/src/test/java/org/apache/shenyu/plugin/metrics/reporter/MetricsReporterTest.java
@@ -58,9 +58,9 @@ public final class MetricsReporterTest {
@Test
public void testRegister() throws Exception {
Map<String, Counter> map1 = getPrivateField(metricsRegister,
"COUNTER_MAP", Map.class);
- Assertions.assertEquals(map1.size(), 9);
+ Assertions.assertEquals(map1.size(), 10);
Map<String, Histogram> map2 = getPrivateField(metricsRegister,
"HISTOGRAM_MAP", Map.class);
- Assertions.assertEquals(map2.size(), 3);
+ Assertions.assertEquals(map2.size(), 4);
List<String> labels = new ArrayList<>();
labels.add("shenyu_request_total");
Collection<Metric> metrics = new ArrayList<>();
@@ -69,9 +69,9 @@ public final class MetricsReporterTest {
metrics.add(new Metric(MetricType.HISTOGRAM, "name3", DOCUMENT,
labels));
MetricsReporter.registerMetrics(metrics);
Map<String, Counter> map3 = getPrivateField(metricsRegister,
"COUNTER_MAP", Map.class);
- Assertions.assertEquals(map3.size(), 10);
+ Assertions.assertEquals(map3.size(), 11);
Map<String, Histogram> map4 = getPrivateField(metricsRegister,
"HISTOGRAM_MAP", Map.class);
- Assertions.assertEquals(map4.size(), 4);
+ Assertions.assertEquals(map4.size(), 5);
Map<String, Gauge> map5 = getPrivateField(metricsRegister,
"GAUGE_MAP", Map.class);
Assertions.assertEquals(map5.size(), 3);
}