You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/10/02 18:13:05 UTC
(camel) branch fix/CAMEL-25075 created (now 9d160e56a7cb)
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a change to branch fix/CAMEL-25075
in repository https://gitbox.apache.org/repos/asf/camel.git
at 9d160e56a7cb CAMEL-25075: camel-jbang-mcp - a repeated identical catalog call answers with a short note
This branch includes the following new commits:
new 9d160e56a7cb CAMEL-25075: camel-jbang-mcp - a repeated identical catalog call answers with a short note
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(camel) 01/01: CAMEL-25075: camel-jbang-mcp - a repeated identical catalog call answers with a short note
Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch fix/CAMEL-25075
in repository https://gitbox.apache.org/repos/asf/camel.git
commit 9d160e56a7cbd13393ed2c89a94a6f6687f5b158
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Fri Oct 2 20:12:52 2026 +0200
CAMEL-25075: camel-jbang-mcp - a repeated identical catalog call answers with a short note
A small model can get stuck asking a catalog tool the same question over and over: every answer is the same, so
nothing in its input changes. The catalog lookups (camel_catalog_doc, camel_catalog_find, camel_catalog_sample,
camel_error_diagnose) are now marked deterministic in the shared tool registry, and both camel mcp and
camel tui --mcp list them with "_meta": {"camel.apache.org/deterministic": true}. From the third identical call
of a session the answer is a short note that the call was already answered, instead of the full answer again.
A session is an MCP connection of camel mcp, and for camel tui --mcp the time from one initialize to the next.
The TUI AI panel already stops identical calls within a turn and is unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
---
.../modules/ROOT/pages/camel-jbang-mcp.adoc | 16 +++
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 4 +
.../jbang/core/commands/ai/RepeatedToolCalls.java | 96 +++++++++++++++++
.../dsl/jbang/core/commands/ai/ToolDescriptor.java | 15 +++
.../core/commands/ai/RepeatedToolCallsTest.java | 90 ++++++++++++++++
.../jbang/core/commands/mcp/AuthoringTools.java | 28 ++++-
.../dsl/jbang/core/commands/mcp/DiagnoseTools.java | 14 +++
.../core/commands/mcp/RepeatedCallSessions.java | 60 +++++++++++
.../core/commands/mcp/AuthoringToolsTest.java | 69 +++++++++++--
.../jbang/core/commands/mcp/DiagnoseToolsTest.java | 40 ++++----
.../dsl/jbang/core/commands/tui/TuiMcpServer.java | 16 ++-
.../tui/TuiMcpServerRepeatedCallsTest.java | 114 +++++++++++++++++++++
12 files changed, 531 insertions(+), 31 deletions(-)
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
index e95cae788727..e5ab97a1ae61 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -402,6 +402,22 @@ project `directory` as an argument, the runtime tools take the integration `name
See xref:camel-jbang-ai.adoc#_project_overview[Project overview].
|===
+==== Repeated calls
+
+The catalog lookups `camel_catalog_doc`, `camel_catalog_find`, `camel_catalog_sample` and `camel_error_diagnose`
+are deterministic: the same arguments always give the same answer. Both servers mark them in the tool listing
+with `"_meta": {"camel.apache.org/deterministic": true}`, so a client can notice repeats itself.
+
+An agent asking one of them the same question again and again gets nothing new, and a small local model can get
+stuck doing that until its tool call budget is spent. So the first two identical calls of a session are answered
+in full, and from the third the answer is a short note instead: that the call was already answered and how many
+times it was asked. The changed answer helps the agent break the loop, and the context is not filled with copies.
+Calls with other arguments, and the tools that read files, logs or the running integration, are always answered
+in full, so re-reading a file after an edit or polling a log keeps working.
+
+A session is an MCP connection of `camel mcp`, and for `camel tui --mcp` the time from one `initialize` of the
+client to the next.
+
=== Catalog Exploration
[cols="1,3",options="header"]
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
index 2d1ff2dd2d84..216298f941e4 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
@@ -100,6 +100,7 @@ public final class AuthoringTools {
.param("optionsFilter", "string", "Keyword to match in option names or descriptions", false)
.param("camelVersion", "string", VERSION_DESC, false)
.core(true)
+ .deterministic(true)
.executor((ctx, args) -> {
applyVersion(ctx, args);
return CatalogDocs.catalogDoc(ctx.catalog(), args.get("name"), args.get("endpoint"),
@@ -120,6 +121,7 @@ public final class AuthoringTools {
.param("limit", "integer", "Maximum matches per kind (default 10)", false)
.param("camelVersion", "string", VERSION_DESC, false)
.core(true)
+ .deterministic(true)
.executor((ctx, args) -> {
applyVersion(ctx, args);
return CatalogDocs.find(ctx.catalog(), args.get("term"), args.get("kind"),
@@ -141,6 +143,7 @@ public final class AuthoringTools {
.param("limit", "integer", "Maximum samples to return (default 2, max 5)", false)
.param("camelVersion", "string", VERSION_DESC, false)
.core(true)
+ .deterministic(true)
.executor((ctx, args) -> {
applyVersion(ctx, args);
return CatalogSamples.sample(ctx.catalog(), args.get("kind"), args.get("name"),
@@ -354,6 +357,7 @@ public final class AuthoringTools {
.param("error", "string", "The stack trace or error message", true)
.param("camelVersion", "string", VERSION_DESC, false)
.core(true)
+ .deterministic(true)
.executor((ctx, args) -> {
applyVersion(ctx, args);
return ErrorDiagnoser.diagnose(required(args, "error"), ctx.catalog()).toJson();
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCalls.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCalls.java
new file mode 100644
index 000000000000..1ea8e2475d75
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCalls.java
@@ -0,0 +1,96 @@
+/*
+ * 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.camel.dsl.jbang.core.commands.ai;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.TreeMap;
+
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * Notices an agent asking a {@link ToolDescriptor#isDeterministic() deterministic} tool the same question over and over
+ * within one session. A small model can get stuck doing that: every answer is the same, so nothing in its input changes
+ * and it asks again. From the third identical call the tool answers with a short note instead of the full answer, so
+ * the input does change and the context is not filled with copies. The first two calls are always answered in full, so
+ * a legitimate re-read still works.
+ * <p>
+ * One instance tracks one session: a client connection, or a conversation of an AI panel. Call {@link #reset()} when a
+ * new session starts on the same instance.
+ */
+public final class RepeatedToolCalls {
+
+ /** The key of the tool listing's {@code _meta} entry that marks a tool as deterministic. */
+ public static final String DETERMINISTIC_META_KEY = "camel.apache.org/deterministic";
+
+ /** How many identical calls are answered in full before the short note is given. */
+ static final int FULL_ANSWERS = 2;
+
+ /** Guards against unbounded growth over a very long session; the oldest counts are simply forgotten. */
+ private static final int MAX_TRACKED = 1000;
+
+ private final Map<String, Integer> counts = new HashMap<>();
+
+ /**
+ * Counts the call, and returns the short note to answer with instead of the full answer, or null when the tool
+ * should run and answer in full: a tool that is not deterministic, or one of the first {@value #FULL_ANSWERS}
+ * identical calls.
+ */
+ public synchronized JsonObject repeatOf(ToolDescriptor tool, Map<String, ?> args) {
+ if (tool == null || !tool.isDeterministic()) {
+ return null;
+ }
+ if (counts.size() >= MAX_TRACKED) {
+ counts.clear();
+ }
+ int times = counts.merge(key(tool.name(), args), 1, Integer::sum);
+ if (times <= FULL_ANSWERS) {
+ return null;
+ }
+ JsonObject note = new JsonObject();
+ note.put("repeated", true);
+ note.put("tool", tool.name());
+ note.put("timesAsked", times);
+ note.put("note", tool.name() + " was already called with these same arguments " + (times - 1)
+ + " times in this session and answered in full. Its answer does not change, so it is not sent"
+ + " again: it is in your earlier tool result.");
+ return note;
+ }
+
+ /** Forgets the calls so far: a new session starts. */
+ public synchronized void reset() {
+ counts.clear();
+ }
+
+ /**
+ * The tool name with its arguments in name order, leaving out the empty ones: a model that sends an optional
+ * argument as an empty string is asking the same question as one that leaves it out.
+ */
+ static String key(String tool, Map<String, ?> args) {
+ Map<String, String> sorted = new TreeMap<>();
+ if (args != null) {
+ for (Map.Entry<String, ?> e : args.entrySet()) {
+ Object v = e.getValue();
+ String s = v != null ? v.toString().trim() : "";
+ if (!s.isEmpty()) {
+ sorted.put(e.getKey(), s);
+ }
+ }
+ }
+ return tool + sorted;
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolDescriptor.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolDescriptor.java
index 6dfed5d00581..a388a1180c49 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolDescriptor.java
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolDescriptor.java
@@ -37,6 +37,7 @@ public class ToolDescriptor {
private boolean readOnly = true;
private boolean destructive = false;
private boolean core = false;
+ private boolean deterministic = false;
public record Param(String name, String type, String description, boolean required) {
}
@@ -82,6 +83,16 @@ public class ToolDescriptor {
return this;
}
+ /**
+ * Marks the tool as deterministic: the same arguments always give the same answer, as a catalog lookup does. A tool
+ * that reads the running integration, the files or the clock is not. Repeating such a call cannot tell an agent
+ * anything new, see {@link RepeatedToolCalls}.
+ */
+ public ToolDescriptor deterministic(boolean v) {
+ deterministic = v;
+ return this;
+ }
+
public ToolDescriptor executor(ToolExecutor exec) {
this.executor = exec;
return this;
@@ -113,6 +124,10 @@ public class ToolDescriptor {
return core;
}
+ public boolean isDeterministic() {
+ return deterministic;
+ }
+
/**
* The JSON schema of the tool's arguments, the way an MCP server lists it under {@code inputSchema} and an LLM
* client sends it as the function parameters: an object with one property per parameter (its type and description)
diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCallsTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCallsTest.java
new file mode 100644
index 000000000000..3883b3380de2
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RepeatedToolCallsTest.java
@@ -0,0 +1,90 @@
+/*
+ * 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.camel.dsl.jbang.core.commands.ai;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class RepeatedToolCallsTest {
+
+ private final RepeatedToolCalls calls = new RepeatedToolCalls();
+ private final ToolDescriptor catalogDoc = ToolRegistry.findTool("camel_catalog_doc");
+
+ @Test
+ void theFirstTwoIdenticalCallsAreAnsweredInFullTheThirdGetsANote() {
+ Map<String, String> sql = Map.of("name", "sql");
+ assertThat(calls.repeatOf(catalogDoc, sql)).isNull();
+ assertThat(calls.repeatOf(catalogDoc, sql)).isNull();
+
+ JsonObject note = calls.repeatOf(catalogDoc, sql);
+ assertThat(note).isNotNull();
+ assertThat(note.getBoolean("repeated")).isTrue();
+ assertThat(note.getString("tool")).isEqualTo("camel_catalog_doc");
+ assertThat(note.getInteger("timesAsked")).isEqualTo(3);
+ assertThat(note.getString("note")).contains("2 times");
+
+ assertThat(calls.repeatOf(catalogDoc, sql).getInteger("timesAsked")).isEqualTo(4);
+ }
+
+ @Test
+ void differentArgumentsAreDifferentQuestions() {
+ for (int i = 0; i < 3; i++) {
+ assertThat(calls.repeatOf(catalogDoc, Map.of("name", "sql", "optionsFilter", "param" + i))).isNull();
+ }
+ }
+
+ @Test
+ void anEmptyOptionalArgumentAsksTheSameAsLeavingItOut() {
+ calls.repeatOf(catalogDoc, Map.of("name", "sql"));
+ calls.repeatOf(catalogDoc, Map.of("name", "sql", "kind", ""));
+ assertThat(calls.repeatOf(catalogDoc, Map.of("kind", " ", "name", "sql"))).isNotNull();
+ }
+
+ @Test
+ void toolsThatReadFilesLogsOrTheRunningIntegrationAreNeverCut() {
+ // a re-read after an edit and polling the log must keep working
+ for (String name : List.of("camel_get_files", "camel_get_log", "camel_get_errors", "camel_validate_source")) {
+ ToolDescriptor tool = ToolRegistry.findTool(name);
+ assertThat(tool.isDeterministic()).as(name).isFalse();
+ for (int i = 0; i < 5; i++) {
+ assertThat(calls.repeatOf(tool, Map.of("file", "a.camel.yaml"))).as(name).isNull();
+ }
+ }
+ }
+
+ @Test
+ void theCatalogLookupsAreDeterministic() {
+ for (String name : List.of("camel_catalog_doc", "camel_catalog_find", "camel_catalog_sample",
+ "camel_error_diagnose")) {
+ assertThat(ToolRegistry.findTool(name).isDeterministic()).as(name).isTrue();
+ }
+ }
+
+ @Test
+ void aNewSessionStartsCountingAgain() {
+ Map<String, String> sql = Map.of("name", "sql");
+ calls.repeatOf(catalogDoc, sql);
+ calls.repeatOf(catalogDoc, sql);
+ calls.reset();
+ assertThat(calls.repeatOf(catalogDoc, sql)).isNull();
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
index 2948c1e2fd00..5423c7d2ad9e 100644
--- a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
+++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
@@ -20,7 +20,10 @@ import java.util.LinkedHashMap;
import java.util.Map;
import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import io.quarkiverse.mcp.server.McpConnection;
+import io.quarkiverse.mcp.server.MetaField;
import io.quarkiverse.mcp.server.Tool;
import io.quarkiverse.mcp.server.ToolArg;
import io.quarkiverse.mcp.server.ToolCallException;
@@ -46,6 +49,9 @@ public class AuthoringTools {
private static final String VERSION_DESC = "Camel version to answer for (default: the CLI's own)";
private static final String DIRECTORY_DESC = "Project directory with the source files (absolute path)";
+ @Inject
+ RepeatedCallSessions repeatedCalls;
+
@Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint = false, openWorldHint = false),
description = "Camel catalog documentation of a component, data format, language, EIP, built-in bean or the "
+ "Java API (description, options, Maven coordinates), with the URI rules of a component. For "
@@ -56,7 +62,9 @@ public class AuthoringTools {
+ "invalid options, missing path. An EIP alias such as fan-out or dedup finds the EIP. "
+ "includeHeaders=true adds the message headers of a component, includeDoc=true the AsciiDoc "
+ "page.")
+ @MetaField(prefix = "camel.apache.org/", name = "deterministic", type = MetaField.Type.BOOLEAN, value = "true")
public JsonObject camel_catalog_doc(
+ McpConnection connection,
@ToolArg(description = "Name, e.g. kafka, json (a data format by its YAML name or artifact), simple, timer, choice, split, Exchange",
required = false) String name,
@ToolArg(description = "Endpoint URI to check, e.g. kafka:orders?brokers=host:9092",
@@ -72,7 +80,7 @@ public class AuthoringTools {
required = false) String docPage,
@ToolArg(description = "Keyword to match in option names or descriptions", required = false) String optionsFilter,
@ToolArg(description = VERSION_DESC, required = false) String camelVersion) {
- return call("camel_catalog_doc", args("name", name, "endpoint", endpoint, "kind", kind,
+ return callDeterministic(connection, "camel_catalog_doc", args("name", name, "endpoint", endpoint, "kind", kind,
"includeOptions", includeOptions, "includeHeaders", includeHeaders, "includeDoc", includeDoc,
"docPage", docPage,
"optionsFilter", optionsFilter, "camelVersion", camelVersion));
@@ -82,13 +90,15 @@ public class AuthoringTools {
description = "Finds Camel components, data formats, languages and EIPs by a protocol, product, alias or "
+ "other term that is not the exact name (mqtt, s3, snowflake, csv, fan-out, dedup): best "
+ "match first with title and description. camel_catalog_doc then gives the options of one.")
+ @MetaField(prefix = "camel.apache.org/", name = "deterministic", type = MetaField.Type.BOOLEAN, value = "true")
public JsonObject camel_catalog_find(
+ McpConnection connection,
@ToolArg(description = "What to look for, e.g. mqtt, s3, database, csv, fan-out", required = true) String term,
@ToolArg(description = "component, dataformat, language, eip or bean (default: all); bean with an interface name such as AggregationStrategy lists the built-in implementations",
required = false) String kind,
@ToolArg(description = "Maximum matches per kind (default 10)", required = false) Integer limit,
@ToolArg(description = VERSION_DESC, required = false) String camelVersion) {
- return call("camel_catalog_find", args("term", term, "kind", kind, "limit", limit,
+ return callDeterministic(connection, "camel_catalog_find", args("term", term, "kind", kind, "limit", limit,
"camelVersion", camelVersion));
}
@@ -97,7 +107,9 @@ public class AuthoringTools {
+ "a component (kafka, file), a data format (csv) or a language (jq) from the docs, with where "
+ "it goes (a top-level entry, a step, an endpoint uri, a marshal step, an expression). Use "
+ "before writing one the first time or after a 'not defined in the schema' error.")
+ @MetaField(prefix = "camel.apache.org/", name = "deterministic", type = MetaField.Type.BOOLEAN, value = "true")
public JsonObject camel_catalog_sample(
+ McpConnection connection,
@ToolArg(description = "EIP, component, data format or language name, or what to do (read file, call "
+ "service, retry, batch)",
required = true) String name,
@@ -106,7 +118,8 @@ public class AuthoringTools {
required = false) String kind,
@ToolArg(description = "Maximum samples to return (default 2, max 5)", required = false) Integer limit,
@ToolArg(description = VERSION_DESC, required = false) String camelVersion) {
- return call("camel_catalog_sample", args("name", name, "kind", kind, "limit", limit, "camelVersion", camelVersion));
+ return callDeterministic(connection, "camel_catalog_sample",
+ args("name", name, "kind", kind, "limit", limit, "camelVersion", camelVersion));
}
@Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint = false, openWorldHint = false),
@@ -285,6 +298,15 @@ public class AuthoringTools {
"model", model, "camelVersion", camelVersion));
}
+ /**
+ * As {@link #call(String, Map)} for a deterministic tool: from the third identical call of the connection the
+ * answer is a short note that it was already answered (CAMEL-25075).
+ */
+ JsonObject callDeterministic(McpConnection connection, String tool, Map<String, String> args) {
+ JsonObject repeat = repeatedCalls != null ? repeatedCalls.repeatOf(connection, tool, args) : null;
+ return repeat != null ? repeat : call(tool, args);
+ }
+
/** Runs the registry tool of the same name and hands its JSON back; a tool error becomes an MCP tool error. */
static JsonObject call(String tool, Map<String, String> args) {
try {
diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/DiagnoseTools.java b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/DiagnoseTools.java
index ff7e0775a962..dbcf9675e46c 100644
--- a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/DiagnoseTools.java
+++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/DiagnoseTools.java
@@ -19,6 +19,8 @@ package org.apache.camel.dsl.jbang.core.commands.mcp;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
+import io.quarkiverse.mcp.server.McpConnection;
+import io.quarkiverse.mcp.server.MetaField;
import io.quarkiverse.mcp.server.Tool;
import io.quarkiverse.mcp.server.ToolArg;
import io.quarkiverse.mcp.server.ToolCallException;
@@ -40,6 +42,9 @@ public class DiagnoseTools {
@Inject
CatalogService catalogService;
+ @Inject
+ RepeatedCallSessions repeatedCalls;
+
/**
* Tool to diagnose Camel errors from stack traces or error messages: a thin wrapper over the shared
* {@code camel_error_diagnose} tool of the {@link ToolRegistry} (CAMEL-24695), so the TUI answers the same.
@@ -50,7 +55,9 @@ public class DiagnoseTools {
+ "links to relevant Camel documentation, and suggested fixes. "
+ "Covers the most common Camel exceptions including NoSuchEndpointException, "
+ "ResolveEndpointFailedException, FailedToCreateRouteException, and more.")
+ @MetaField(prefix = "camel.apache.org/", name = "deterministic", type = MetaField.Type.BOOLEAN, value = "true")
public JsonObject camel_error_diagnose(
+ McpConnection connection,
@ToolArg(description = "The Camel stack trace or error message to diagnose", required = true) String error,
@ToolArg(description = ToolArgDocs.RUNTIME, required = false) String runtime,
@ToolArg(description = ToolArgDocs.CAMEL_VERSION, required = false) String camelVersion,
@@ -59,6 +66,13 @@ public class DiagnoseTools {
if (error == null || error.isBlank()) {
throw new ToolCallException("Error message or stack trace is required", null);
}
+ JsonObject repeat = repeatedCalls != null
+ ? repeatedCalls.repeatOf(connection, "camel_error_diagnose", AuthoringTools.args("error", error,
+ "runtime", runtime, "camelVersion", camelVersion, "platformBom", platformBom))
+ : null;
+ if (repeat != null) {
+ return repeat;
+ }
try {
CamelCatalog catalog = catalogService.loadCatalog(runtime, camelVersion, platformBom);
return ErrorDiagnoser.diagnose(error, catalog);
diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RepeatedCallSessions.java b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RepeatedCallSessions.java
new file mode 100644
index 000000000000..7af99b2cc596
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RepeatedCallSessions.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.mcp;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import jakarta.enterprise.context.ApplicationScoped;
+
+import io.quarkiverse.mcp.server.McpConnection;
+import org.apache.camel.dsl.jbang.core.commands.ai.RepeatedToolCalls;
+import org.apache.camel.dsl.jbang.core.commands.ai.ToolRegistry;
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * The {@link RepeatedToolCalls} of each MCP connection, so a client that asks a deterministic tool the same question
+ * over and over gets a short note from the third time instead of the same full answer (CAMEL-25075).
+ */
+@ApplicationScoped
+public class RepeatedCallSessions {
+
+ /** Connections kept; the least recently used is forgotten beyond this. */
+ private static final int MAX_SESSIONS = 64;
+
+ private final Map<String, RepeatedToolCalls> sessions = new LinkedHashMap<>(16, 0.75f, true) {
+ @Override
+ protected boolean removeEldestEntry(Map.Entry<String, RepeatedToolCalls> eldest) {
+ return size() > MAX_SESSIONS;
+ }
+ };
+
+ /**
+ * The short note to answer with instead of running the tool, or null to run it: the tool is not deterministic, the
+ * call is one of the first identical ones, or there is no connection to tell the sessions apart.
+ */
+ JsonObject repeatOf(McpConnection connection, String tool, Map<String, String> args) {
+ if (connection == null || connection.id() == null) {
+ return null;
+ }
+ RepeatedToolCalls calls;
+ synchronized (sessions) {
+ calls = sessions.computeIfAbsent(connection.id(), id -> new RepeatedToolCalls());
+ }
+ return calls.repeatOf(ToolRegistry.findTool(tool), args);
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java
index 3759d6edfbff..07613b2420a9 100644
--- a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java
+++ b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java
@@ -18,13 +18,17 @@ package org.apache.camel.dsl.jbang.core.commands.mcp;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
+import java.lang.reflect.Proxy;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.List;
import java.util.Map;
+import io.quarkiverse.mcp.server.McpConnection;
+import io.quarkiverse.mcp.server.MetaField;
import io.quarkiverse.mcp.server.Tool;
import io.quarkiverse.mcp.server.ToolArg;
import io.quarkiverse.mcp.server.ToolCallException;
@@ -68,17 +72,18 @@ class AuthoringToolsTest {
@Test
void catalogDocAnswersWithOptionsAndUriRules() {
- JsonObject timer = tools.camel_catalog_doc("timer", null, "component", null, null, null, null, null, null);
+ JsonObject timer = tools.camel_catalog_doc(null, "timer", null, "component", null, null, null, null, null, null);
assertThat(timer.getString("kind")).isEqualTo("component");
assertThat(timer.getString("uriSyntax")).contains("(timerName) go in the path");
assertThat(timer.getInteger("matchedOptions")).isGreaterThan(5);
- JsonObject check = tools.camel_catalog_doc(null, "timer:tick?periodd=5s", null, null, null, null, null, null, null);
+ JsonObject check
+ = tools.camel_catalog_doc(null, null, "timer:tick?periodd=5s", null, null, null, null, null, null, null);
assertThat(check.getBoolean("valid")).isFalse();
assertThat(List.copyOf(check.getCollection("problems")).get(0).toString()).contains("periodd");
// a lookup that finds nothing answers with an error field and suggestions, as the TUI does, not an exception
- assertThat(tools.camel_catalog_doc(null, null, null, null, null, null, null, null, null).getString("error"))
+ assertThat(tools.camel_catalog_doc(null, null, null, null, null, null, null, null, null, null).getString("error"))
.contains("required");
- JsonObject mqtt = tools.camel_catalog_doc("mqtt", null, "component", null, null, null, null, null, null);
+ JsonObject mqtt = tools.camel_catalog_doc(null, "mqtt", null, "component", null, null, null, null, null, null);
assertThat(mqtt.getString("error")).contains("mqtt");
assertThat(List.copyOf(mqtt.getCollection("suggestions")).toString()).contains("paho-mqtt5");
}
@@ -122,7 +127,9 @@ class AuthoringToolsTest {
}
ToolDescriptor td = ToolRegistry.findTool(m.getName());
List<ToolDescriptor.Param> params = td.params();
- Parameter[] args = m.getParameters();
+ // the connection is injected by the server, not an argument of the tool
+ Parameter[] args = Arrays.stream(m.getParameters())
+ .filter(p -> p.getType() != McpConnection.class).toArray(Parameter[]::new);
assertThat(args).as(m.getName() + " has one argument per descriptor parameter").hasSize(params.size());
for (int i = 0; i < args.length; i++) {
ToolArg arg = args[i].getAnnotation(ToolArg.class);
@@ -133,13 +140,61 @@ class AuthoringToolsTest {
}
}
+ @Test
+ void theListingMarksAToolDeterministicExactlyWhenTheSharedDescriptorSaysSo() {
+ for (Class<?> type : List.of(AuthoringTools.class, DiagnoseTools.class)) {
+ for (Method m : type.getMethods()) {
+ ToolDescriptor td = m.getAnnotation(Tool.class) != null ? ToolRegistry.findTool(m.getName()) : null;
+ if (td == null) {
+ continue;
+ }
+ MetaField meta = m.getAnnotation(MetaField.class);
+ boolean marked = meta != null && "camel.apache.org/".equals(meta.prefix())
+ && "deterministic".equals(meta.name()) && "true".equals(meta.value());
+ assertThat(marked).as(m.getName() + " marked deterministic").isEqualTo(td.isDeterministic());
+ boolean connected = Arrays.stream(m.getParameterTypes()).anyMatch(t -> t == McpConnection.class);
+ assertThat(connected).as(m.getName() + " gets the connection to count its repeats")
+ .isEqualTo(td.isDeterministic());
+ }
+ }
+ }
+
+ @Test
+ void aThirdIdenticalCatalogCallOfAConnectionGetsAShortNote() {
+ AuthoringTools counted = new AuthoringTools();
+ counted.repeatedCalls = new RepeatedCallSessions();
+ McpConnection agent = connection("agent-1");
+
+ JsonObject first = counted.camel_catalog_doc(agent, "sql", null, null, null, null, null, null, null, null);
+ JsonObject second = counted.camel_catalog_doc(agent, "sql", null, null, null, null, null, null, null, null);
+ JsonObject third = counted.camel_catalog_doc(agent, "sql", null, null, null, null, null, null, null, null);
+
+ assertThat(first.get("repeated")).isNull();
+ assertThat(second).isEqualTo(first);
+ assertThat(third.getBoolean("repeated")).isTrue();
+ assertThat(third.getInteger("timesAsked")).isEqualTo(3);
+ assertThat(third.getString("note")).contains("camel_catalog_doc", "2 times");
+ assertThat(third.toJson().length()).isLessThan(first.toJson().length() / 4);
+
+ // another question, or another connection, is answered in full
+ assertThat(counted.camel_catalog_doc(agent, "timer", null, null, null, null, null, null, null, null)
+ .get("repeated")).isNull();
+ assertThat(counted.camel_catalog_doc(connection("agent-2"), "sql", null, null, null, null, null, null, null, null)
+ .get("repeated")).isNull();
+ }
+
+ private static McpConnection connection(String id) {
+ return (McpConnection) Proxy.newProxyInstance(McpConnection.class.getClassLoader(),
+ new Class<?>[] { McpConnection.class }, (proxy, method, args) -> "id".equals(method.getName()) ? id : null);
+ }
+
@Test
void blankArgumentsAreLeftOutSoTheToolDefaultApplies() {
Map<String, String> args = AuthoringTools.args("name", "timer", "kind", "", "limit", 3, "dev", true, "x", null);
assertThat(args).containsExactly(Map.entry("name", "timer"), Map.entry("limit", "3"), Map.entry("dev", "true"));
// a blank kind means auto-detect, not "a kind called nothing"
- assertThat(tools.camel_catalog_doc("timer", "", "", null, null, null, "", "period", "").getString("kind"))
+ assertThat(tools.camel_catalog_doc(null, "timer", "", "", null, null, null, "", "period", "").getString("kind"))
.isEqualTo("component");
- assertThat(tools.camel_catalog_find("mqtt", "", null, "").getInteger("count")).isGreaterThan(0);
+ assertThat(tools.camel_catalog_find(null, "mqtt", "", null, "").getInteger("count")).isGreaterThan(0);
}
}
diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/DiagnoseToolsTest.java b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/DiagnoseToolsTest.java
index 0fad8f0e5801..58c620b4382c 100644
--- a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/DiagnoseToolsTest.java
+++ b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/DiagnoseToolsTest.java
@@ -41,14 +41,14 @@ class DiagnoseToolsTest {
@Test
void nullErrorThrows() {
- assertThatThrownBy(() -> tools.camel_error_diagnose(null, null, null, null))
+ assertThatThrownBy(() -> tools.camel_error_diagnose(null, null, null, null, null))
.isInstanceOf(ToolCallException.class)
.hasMessageContaining("required");
}
@Test
void blankErrorThrows() {
- assertThatThrownBy(() -> tools.camel_error_diagnose(" ", null, null, null))
+ assertThatThrownBy(() -> tools.camel_error_diagnose(null, " ", null, null, null))
.isInstanceOf(ToolCallException.class)
.hasMessageContaining("required");
}
@@ -59,7 +59,7 @@ class DiagnoseToolsTest {
void identifiesNoSuchEndpointException() {
String error = "org.apache.camel.NoSuchEndpointException: No endpoint could be found for: kafak:myTopic";
- JsonObject result = tools.camel_error_diagnose(error, null, null, null);
+ JsonObject result = tools.camel_error_diagnose(null, error, null, null, null);
assertThat(exceptions(result)).isNotEmpty();
assertThat(firstException(result)).isEqualTo("NoSuchEndpointException");
@@ -71,7 +71,7 @@ class DiagnoseToolsTest {
+ "Failed to resolve endpoint: kafka:myTopic?unknownOption=value due to: "
+ "There are 1 parameters that couldn't be set on the endpoint.";
- JsonObject result = tools.camel_error_diagnose(error, null, null, null);
+ JsonObject result = tools.camel_error_diagnose(null, error, null, null, null);
assertThat(exceptions(result)).isNotEmpty();
assertThat(firstException(result)).isEqualTo("ResolveEndpointFailedException");
@@ -82,7 +82,7 @@ class DiagnoseToolsTest {
String error = "org.apache.camel.FailedToCreateRouteException: "
+ "Failed to create route route1: Route(route1)[From[direct:start] -> [To[log:out]]]";
- JsonObject result = tools.camel_error_diagnose(error, null, null, null);
+ JsonObject result = tools.camel_error_diagnose(null, error, null, null, null);
assertThat(exceptions(result)).isNotEmpty();
assertThat(firstException(result)).isEqualTo("FailedToCreateRouteException");
@@ -93,7 +93,7 @@ class DiagnoseToolsTest {
String error = "org.apache.camel.FailedToCreateRouteException: Failed to create route\n"
+ "Caused by: org.apache.camel.ResolveEndpointFailedException: Failed to resolve endpoint";
- JsonObject result = tools.camel_error_diagnose(error, null, null, null);
+ JsonObject result = tools.camel_error_diagnose(null, error, null, null, null);
assertThat(exceptions(result).size()).isGreaterThanOrEqualTo(2);
}
@@ -104,7 +104,7 @@ class DiagnoseToolsTest {
+ "No type converter available to convert from type: java.lang.String "
+ "to the required type: java.io.InputStream";
- JsonObject result = tools.camel_error_diagnose(error, null, null, null);
+ JsonObject result = tools.camel_error_diagnose(null, error, null, null, null);
assertThat(exceptions(result)).isNotEmpty();
assertThat(firstException(result)).isEqualTo("NoTypeConversionAvailableException");
@@ -115,7 +115,7 @@ class DiagnoseToolsTest {
String error = "org.apache.camel.ExchangeTimedOutException: "
+ "The OUT message was not received within: 30000 millis";
- JsonObject result = tools.camel_error_diagnose(error, null, null, null);
+ JsonObject result = tools.camel_error_diagnose(null, error, null, null, null);
assertThat(exceptions(result)).isNotEmpty();
assertThat(firstException(result)).isEqualTo("ExchangeTimedOutException");
@@ -126,7 +126,7 @@ class DiagnoseToolsTest {
String error = "org.apache.camel.component.direct.DirectConsumerNotAvailableException: "
+ "No consumers available on endpoint: direct://myEndpoint";
- JsonObject result = tools.camel_error_diagnose(error, null, null, null);
+ JsonObject result = tools.camel_error_diagnose(null, error, null, null, null);
assertThat(exceptions(result)).isNotEmpty();
assertThat(firstException(result)).isEqualTo("DirectConsumerNotAvailableException");
@@ -137,7 +137,7 @@ class DiagnoseToolsTest {
String error = "org.apache.camel.PropertyBindingException: "
+ "Error binding property (brokerz=localhost:9092) with name: brokerz on bean";
- JsonObject result = tools.camel_error_diagnose(error, null, null, null);
+ JsonObject result = tools.camel_error_diagnose(null, error, null, null, null);
assertThat(exceptions(result)).isNotEmpty();
assertThat(firstException(result)).isEqualTo("PropertyBindingException");
@@ -148,7 +148,7 @@ class DiagnoseToolsTest {
String error = "org.apache.camel.NoSuchBeanException: "
+ "No bean could be found in the registry for: myProcessor";
- JsonObject result = tools.camel_error_diagnose(error, null, null, null);
+ JsonObject result = tools.camel_error_diagnose(null, error, null, null, null);
assertThat(exceptions(result)).isNotEmpty();
assertThat(firstException(result)).isEqualTo("NoSuchBeanException");
@@ -161,7 +161,7 @@ class DiagnoseToolsTest {
String error = "org.apache.camel.ResolveEndpointFailedException: "
+ "Failed to resolve endpoint: kafka:myTopic?brokers=localhost:9092";
- JsonObject result = tools.camel_error_diagnose(error, null, null, null);
+ JsonObject result = tools.camel_error_diagnose(null, error, null, null, null);
assertThat(components(result)).isNotEmpty();
assertThat(components(result).stream().map(c -> c.getString("name")).toList()).contains("kafka");
@@ -171,7 +171,7 @@ class DiagnoseToolsTest {
void identifiesDirectComponent() {
String error = "No consumers available on endpoint: direct://start";
- JsonObject result = tools.camel_error_diagnose(error, null, null, null);
+ JsonObject result = tools.camel_error_diagnose(null, error, null, null, null);
assertThat(components(result).stream().map(c -> c.getString("name")).toList()).contains("direct");
}
@@ -182,7 +182,7 @@ class DiagnoseToolsTest {
void resultContainsCommonCauses() {
String error = "org.apache.camel.NoSuchEndpointException: No endpoint could be found for: xyz:test";
- JsonObject result = tools.camel_error_diagnose(error, null, null, null);
+ JsonObject result = tools.camel_error_diagnose(null, error, null, null, null);
assertThat(List.copyOf(exceptions(result).get(0).getCollection("commonCauses"))).isNotEmpty();
}
@@ -191,7 +191,7 @@ class DiagnoseToolsTest {
void resultContainsSuggestedFixes() {
String error = "org.apache.camel.NoSuchEndpointException: No endpoint could be found for: xyz:test";
- JsonObject result = tools.camel_error_diagnose(error, null, null, null);
+ JsonObject result = tools.camel_error_diagnose(null, error, null, null, null);
assertThat(List.copyOf(exceptions(result).get(0).getCollection("suggestedFixes"))).isNotEmpty();
}
@@ -200,7 +200,7 @@ class DiagnoseToolsTest {
void resultContainsDocumentationLinks() {
String error = "org.apache.camel.NoSuchEndpointException: No endpoint could be found for: xyz:test";
- JsonObject result = tools.camel_error_diagnose(error, null, null, null);
+ JsonObject result = tools.camel_error_diagnose(null, error, null, null, null);
List<String> links = List.copyOf(exceptions(result).get(0).getCollection("documentationLinks"));
assertThat(links).isNotEmpty();
@@ -211,7 +211,7 @@ class DiagnoseToolsTest {
void resultContainsSummary() {
String error = "org.apache.camel.NoSuchEndpointException: No endpoint";
- JsonObject result = tools.camel_error_diagnose(error, null, null, null);
+ JsonObject result = tools.camel_error_diagnose(null, error, null, null, null);
JsonObject summary = result.getMap("summary");
assertThat(summary).isNotNull();
@@ -223,7 +223,7 @@ class DiagnoseToolsTest {
void componentDocumentationUrlPresent() {
String error = "Failed to resolve endpoint: kafka:myTopic";
- JsonObject result = tools.camel_error_diagnose(error, null, null, null);
+ JsonObject result = tools.camel_error_diagnose(null, error, null, null, null);
if (!components(result).isEmpty()) {
assertThat(components(result).get(0).getString("documentationUrl")).contains("camel.apache.org");
@@ -236,7 +236,7 @@ class DiagnoseToolsTest {
void unrecognizedErrorReturnsDiagnosedFalse() {
String error = "Some random error that is not a Camel exception";
- JsonObject result = tools.camel_error_diagnose(error, null, null, null);
+ JsonObject result = tools.camel_error_diagnose(null, error, null, null, null);
JsonObject summary = result.getMap("summary");
assertThat(summary.getBoolean("diagnosed")).isFalse();
@@ -259,7 +259,7 @@ class DiagnoseToolsTest {
\tat org.apache.camel.component.direct.DirectComponent.createEndpoint(DirectComponent.java:62)
""";
- JsonObject result = tools.camel_error_diagnose(stackTrace, null, null, null);
+ JsonObject result = tools.camel_error_diagnose(null, stackTrace, null, null, null);
// Should identify all three exceptions in the chain
assertThat(exceptions(result).size()).isGreaterThanOrEqualTo(3);
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
index 2c39d7c18768..8f1ea05e4d5e 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
@@ -31,6 +31,9 @@ import java.util.concurrent.atomic.AtomicInteger;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
+import org.apache.camel.dsl.jbang.core.commands.ai.RepeatedToolCalls;
+import org.apache.camel.dsl.jbang.core.commands.ai.ToolDescriptor;
+import org.apache.camel.dsl.jbang.core.commands.ai.ToolRegistry;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
import org.apache.camel.util.json.Jsoner;
@@ -69,6 +72,8 @@ class TuiMcpServer {
private final int port;
private final McpFacade facade;
private final TuiToolRegistry toolRegistry;
+ /** The calls of the connected client; a new {@code initialize} starts a new session. */
+ private final RepeatedToolCalls repeatedCalls = new RepeatedToolCalls();
private HttpServer server;
private volatile String clientName;
private volatile long lastActivity;
@@ -252,6 +257,7 @@ class TuiMcpServer {
clientName = (String) clientInfo.get("name");
}
}
+ repeatedCalls.reset();
JsonObject result = new JsonObject();
result.put("protocolVersion", PROTOCOL_VERSION);
@@ -281,6 +287,12 @@ class TuiMcpServer {
tool.put("name", td.name());
tool.put("description", td.description());
tool.put("inputSchema", td.inputSchema());
+ ToolDescriptor shared = ToolRegistry.findTool(td.name());
+ if (shared != null && shared.isDeterministic()) {
+ JsonObject meta = new JsonObject();
+ meta.put(RepeatedToolCalls.DETERMINISTIC_META_KEY, true);
+ tool.put("_meta", meta);
+ }
toolList.add(tool);
}
JsonObject result = new JsonObject();
@@ -500,7 +512,9 @@ class TuiMcpServer {
String text;
boolean isError = false;
try {
- text = toolRegistry.execute(toolName, args);
+ JsonObject repeat = toolName != null
+ ? repeatedCalls.repeatOf(ToolRegistry.findTool(toolName), args) : null;
+ text = repeat != null ? repeat.toJson() : toolRegistry.execute(toolName, args);
} catch (IllegalArgumentException e) {
text = "Unknown tool: " + toolName;
isError = true;
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServerRepeatedCallsTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServerRepeatedCallsTest.java
new file mode 100644
index 000000000000..aee96bf1ab10
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServerRepeatedCallsTest.java
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.Map;
+
+import org.apache.camel.dsl.jbang.core.commands.ai.RepeatedToolCalls;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A client of {@code camel tui --mcp} that asks a catalog tool the same question over and over gets a short note from
+ * the third time (CAMEL-25075).
+ */
+class TuiMcpServerRepeatedCallsTest {
+
+ private final HttpClient http = HttpClient.newHttpClient();
+ private TuiMcpServer server;
+ private int id;
+
+ @BeforeEach
+ void start() throws Exception {
+ server = new TuiMcpServer(0, null);
+ server.start();
+ rpc("initialize", new JsonObject(Map.of("clientInfo", new JsonObject(Map.of("name", "test")))));
+ }
+
+ @AfterEach
+ void stop() {
+ server.stop();
+ }
+
+ @Test
+ void theListingMarksTheCatalogLookupsDeterministic() throws Exception {
+ JsonArray tools = (JsonArray) rpc("tools/list", new JsonObject()).get("tools");
+ JsonObject catalogDoc = null;
+ JsonObject getFiles = null;
+ for (Object o : tools) {
+ JsonObject tool = (JsonObject) o;
+ if ("camel_catalog_doc".equals(tool.getString("name"))) {
+ catalogDoc = tool;
+ } else if ("camel_get_files".equals(tool.getString("name"))) {
+ getFiles = tool;
+ }
+ }
+ assertThat(catalogDoc).isNotNull();
+ assertThat(((JsonObject) catalogDoc.get("_meta")).getBoolean(RepeatedToolCalls.DETERMINISTIC_META_KEY)).isTrue();
+ assertThat(getFiles).isNotNull();
+ assertThat(getFiles.get("_meta")).isNull();
+ }
+
+ @Test
+ void aThirdIdenticalCallGetsAShortNoteUntilTheClientConnectsAgain() throws Exception {
+ String first = callCatalogDoc("timer");
+ String second = callCatalogDoc("timer");
+ String third = callCatalogDoc("timer");
+
+ assertThat(second).isEqualTo(first);
+ JsonObject note = (JsonObject) Jsoner.deserialize(third);
+ assertThat(note.getBoolean("repeated")).isTrue();
+ assertThat(note.getInteger("timesAsked")).isEqualTo(3);
+ assertThat(third.length()).isLessThan(first.length() / 4);
+
+ // a new initialize is a new session: the agent has not seen the answer there
+ rpc("initialize", new JsonObject(Map.of("clientInfo", new JsonObject(Map.of("name", "test")))));
+ assertThat(callCatalogDoc("timer")).isEqualTo(first);
+ }
+
+ private String callCatalogDoc(String name) throws Exception {
+ JsonObject params = new JsonObject();
+ params.put("name", "camel_catalog_doc");
+ params.put("arguments", new JsonObject(Map.of("name", name, "kind", "component")));
+ JsonArray content = (JsonArray) rpc("tools/call", params).get("content");
+ return ((JsonObject) content.get(0)).getString("text");
+ }
+
+ private JsonObject rpc(String method, JsonObject params) throws Exception {
+ JsonObject request = new JsonObject();
+ request.put("jsonrpc", "2.0");
+ request.put("id", ++id);
+ request.put("method", method);
+ request.put("params", params);
+ HttpRequest post = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + server.getPort() + "/mcp"))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(request.toJson()))
+ .build();
+ String body = http.send(post, HttpResponse.BodyHandlers.ofString()).body();
+ return (JsonObject) ((JsonObject) Jsoner.deserialize(body)).get("result");
+ }
+}