This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-25075 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 9d160e56a7cbd13393ed2c89a94a6f6687f5b158 Author: Claus Ibsen <[email protected]> AuthorDate: Fri Oct 2 20:12:52 2026 +0200 CAMEL-25075: camel-jbang-mcp - a repeated identical catalog call answers with a short note A small model can get stuck asking a catalog tool the same question over and over: every answer is the same, so nothing in its input changes. The catalog lookups (camel_catalog_doc, camel_catalog_find, camel_catalog_sample, camel_error_diagnose) are now marked deterministic in the shared tool registry, and both camel mcp and camel tui --mcp list them with "_meta": {"camel.apache.org/deterministic": true}. From the third identical call of a session the answer is a short note that the call was already answered, instead of the full answer again. A session is an MCP connection of camel mcp, and for camel tui --mcp the time from one initialize to the next. The TUI AI panel already stops identical calls within a turn and is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <[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"); + } +}
