This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 201a97078a63 CAMEL-25075: camel-jbang-mcp - a repeated identical 
catalog call answers with a short note (#27298)
201a97078a63 is described below

commit 201a97078a639374363756670353796444757af4
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Oct 2 21:13:36 2026 +0200

    CAMEL-25075: camel-jbang-mcp - a repeated identical catalog call answers 
with a short note (#27298)
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../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");
+    }
+}

Reply via email to