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");
+ }
+}