This is an automated email from the ASF dual-hosted git repository.
Croway 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 e69f851c07dc CAMEL-24309: camel-ai-tool - AiToolRegistry listener SPI
for tool registration changes
e69f851c07dc is described below
commit e69f851c07dce559fad3ee8fb9876a451b0ee18d
Author: croway <[email protected]>
AuthorDate: Mon Aug 3 09:31:06 2026 +0200
CAMEL-24309: camel-ai-tool - AiToolRegistry listener SPI for tool
registration changes
Add AiToolRegistryListener with toolRegistered/toolDeregistered callbacks
fired on ai-tool consumer lifecycle events (route start/resume registers,
stop/suspend deregisters). Callbacks fire outside the registry lock, only
on actual state changes, and a failing listener cannot break registration.
Prerequisite for MCP tools/list_changed notifications (CAMEL-24308).
Co-Authored-By: Claude Fable 5 <[email protected]>
---
.../camel/component/ai/tool/AiToolRegistry.java | 68 ++++++++-
.../component/ai/tool/AiToolRegistryListener.java | 52 +++++++
.../tool/AiToolRegistryListenerLifecycleTest.java | 144 +++++++++++++++++++
.../ai/tool/AiToolRegistryListenerTest.java | 158 +++++++++++++++++++++
4 files changed, 418 insertions(+), 4 deletions(-)
diff --git
a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolRegistry.java
b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolRegistry.java
index c47562fd820c..8c3d71372e93 100644
---
a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolRegistry.java
+++
b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolRegistry.java
@@ -19,11 +19,15 @@ package org.apache.camel.component.ai.tool;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
+import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.locks.ReentrantLock;
import org.apache.camel.CamelContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/**
* CamelContext-scoped registry mapping tags to {@link AiToolSpec} instances.
AI components (LangChain4j, Spring AI)
@@ -32,6 +36,9 @@ import org.apache.camel.CamelContext;
* Each {@link CamelContext} gets its own registry instance, registered as a
context plugin. Use
* {@link #getOrCreate(CamelContext)} to obtain the instance for a given
context.
* <p>
+ * Adapters that need to react to tools appearing or disappearing (e.g. to
push MCP {@code tools/list_changed}
+ * notifications) can register an {@link AiToolRegistryListener} instead of
polling.
+ * <p>
* Replaces the duplicated {@code CamelToolExecutorCache} singletons from
{@code camel-langchain4j-tools} and
* {@code camel-spring-ai-tools}.
*
@@ -39,11 +46,14 @@ import org.apache.camel.CamelContext;
*/
public final class AiToolRegistry {
+ private static final Logger LOG =
LoggerFactory.getLogger(AiToolRegistry.class);
+
private static final ReentrantLock FACTORY_LOCK = new ReentrantLock();
private final ReentrantLock lock = new ReentrantLock();
private final Map<String, Set<AiToolSpec>> tools;
private final Set<AiToolSpec> defaultTools;
+ private final List<AiToolRegistryListener> listeners = new
CopyOnWriteArrayList<>();
AiToolRegistry() {
tools = new HashMap<>();
@@ -71,6 +81,7 @@ public final class AiToolRegistry {
}
public void put(String tag, AiToolSpec spec) {
+ boolean added;
lock.lock();
try {
Set<AiToolSpec> set = tools.computeIfAbsent(tag, k -> new
LinkedHashSet<>());
@@ -81,18 +92,22 @@ public final class AiToolRegistry {
+ "': tool names must
be unique per tag");
}
}
- set.add(spec);
+ added = set.add(spec);
} finally {
lock.unlock();
}
+ if (added) {
+ notifyRegistered(tag, spec);
+ }
}
public void remove(String tag, AiToolSpec spec) {
+ boolean removed = false;
lock.lock();
try {
Set<AiToolSpec> set = tools.get(tag);
if (set != null) {
- set.remove(spec);
+ removed = set.remove(spec);
if (set.isEmpty()) {
tools.remove(tag);
}
@@ -100,9 +115,13 @@ public final class AiToolRegistry {
} finally {
lock.unlock();
}
+ if (removed) {
+ notifyDeregistered(tag, spec);
+ }
}
public void putDefault(AiToolSpec spec) {
+ boolean added;
lock.lock();
try {
for (AiToolSpec existing : defaultTools) {
@@ -112,19 +131,60 @@ public final class AiToolRegistry {
+ "' in the default
pool: tool names must be unique");
}
}
- defaultTools.add(spec);
+ added = defaultTools.add(spec);
} finally {
lock.unlock();
}
+ if (added) {
+ notifyRegistered(null, spec);
+ }
}
public void removeDefault(AiToolSpec spec) {
+ boolean removed;
lock.lock();
try {
- defaultTools.remove(spec);
+ removed = defaultTools.remove(spec);
} finally {
lock.unlock();
}
+ if (removed) {
+ notifyDeregistered(null, spec);
+ }
+ }
+
+ /**
+ * Adds a listener notified on tool registration changes. See {@link
AiToolRegistryListener} for the callback
+ * contract and the subscribe-then-snapshot idiom to observe current state
without missing events.
+ */
+ public void addListener(AiToolRegistryListener listener) {
+ listeners.add(listener);
+ }
+
+ public void removeListener(AiToolRegistryListener listener) {
+ listeners.remove(listener);
+ }
+
+ private void notifyRegistered(String tag, AiToolSpec spec) {
+ for (AiToolRegistryListener listener : listeners) {
+ try {
+ listener.toolRegistered(tag, spec);
+ } catch (Exception e) {
+ LOG.warn("AiToolRegistryListener {} failed on toolRegistered
for tool '{}': {}",
+ listener, spec.getName(), e.getMessage(), e);
+ }
+ }
+ }
+
+ private void notifyDeregistered(String tag, AiToolSpec spec) {
+ for (AiToolRegistryListener listener : listeners) {
+ try {
+ listener.toolDeregistered(tag, spec);
+ } catch (Exception e) {
+ LOG.warn("AiToolRegistryListener {} failed on toolDeregistered
for tool '{}': {}",
+ listener, spec.getName(), e.getMessage(), e);
+ }
+ }
}
/**
diff --git
a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolRegistryListener.java
b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolRegistryListener.java
new file mode 100644
index 000000000000..378842f53e27
--- /dev/null
+++
b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolRegistryListener.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.ai.tool;
+
+/**
+ * Listener notified when tool specifications are registered to or
deregistered from an {@link AiToolRegistry}.
+ * <p>
+ * Registration events are driven by the {@code ai-tool} consumer lifecycle: a
tool is registered when its route starts
+ * or resumes, and deregistered when its route stops or suspends. A tool
endpoint declaring multiple tags fires one
+ * event per tag.
+ * <p>
+ * Callbacks are invoked outside the registry lock, on the thread performing
the (de)registration — typically a route
+ * lifecycle thread. Implementations must be thread-safe and non-blocking; a
callback that throws is logged and does not
+ * affect the registration itself or other listeners.
+ * <p>
+ * To observe the current registry state without missing concurrent changes,
add the listener first and then read a
+ * snapshot (e.g. {@link AiToolRegistry#getTools()}), tolerating events that
duplicate snapshot content.
+ *
+ * @since 4.22
+ */
+public interface AiToolRegistryListener {
+
+ /**
+ * Called after a tool specification has been registered.
+ *
+ * @param tag the tag the tool was registered under, or {@code null} for
the default (untagged) pool
+ * @param spec the registered tool specification
+ */
+ void toolRegistered(String tag, AiToolSpec spec);
+
+ /**
+ * Called after a tool specification has been deregistered.
+ *
+ * @param tag the tag the tool was deregistered from, or {@code null} for
the default (untagged) pool
+ * @param spec the deregistered tool specification
+ */
+ void toolDeregistered(String tag, AiToolSpec spec);
+}
diff --git
a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryListenerLifecycleTest.java
b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryListenerLifecycleTest.java
new file mode 100644
index 000000000000..bba34ceea07d
--- /dev/null
+++
b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryListenerLifecycleTest.java
@@ -0,0 +1,144 @@
+/*
+ * 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.component.ai.tool;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.tuple;
+
+/**
+ * Verifies that {@link AiToolRegistryListener} callbacks are driven by the
{@code ai-tool} consumer lifecycle: route
+ * start/resume registers, route stop/suspend deregisters.
+ */
+class AiToolRegistryListenerLifecycleTest extends CamelTestSupport {
+
+ private final RecordingListener listener = new RecordingListener();
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext camelContext = super.createCamelContext();
+ AiToolRegistry.getOrCreate(camelContext).addListener(listener);
+ return camelContext;
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ public void configure() {
+ from("ai-tool:getWeather?tags=weather&description=Get the
weather")
+ .routeId("weather-route")
+ .setBody(constant("sunny"));
+ }
+ };
+ }
+
+ @Test
+ void testEventsOnRouteStartAndStop() throws Exception {
+ assertThat(listener.events)
+ .as("Route start should fire toolRegistered")
+ .extracting(Event::type, Event::tag, Event::toolName)
+ .containsExactly(tuple("registered", "weather", "getWeather"));
+
+ context.getRouteController().stopRoute("weather-route");
+
+ assertThat(listener.events)
+ .extracting(Event::type, Event::tag, Event::toolName)
+ .containsExactly(
+ tuple("registered", "weather", "getWeather"),
+ tuple("deregistered", "weather", "getWeather"));
+ }
+
+ @Test
+ void testEventsOnSuspendAndResume() throws Exception {
+ context.getRouteController().suspendRoute("weather-route");
+ context.getRouteController().resumeRoute("weather-route");
+
+ assertThat(listener.events)
+ .extracting(Event::type, Event::tag, Event::toolName)
+ .containsExactly(
+ tuple("registered", "weather", "getWeather"),
+ tuple("deregistered", "weather", "getWeather"),
+ tuple("registered", "weather", "getWeather"));
+ }
+
+ @Test
+ void testMultiTagEndpointFiresOneEventPerTag() throws Exception {
+ context.addRoutes(new RouteBuilder() {
+ public void configure() {
+ from("ai-tool:sendEmail?tags=notify,crm&description=Send an
email")
+ .routeId("email-route")
+ .setBody(constant("sent"));
+ }
+ });
+
+ assertThat(listener.events)
+ .filteredOn(e -> "sendEmail".equals(e.toolName()))
+ .extracting(Event::type, Event::tag)
+ .containsExactlyInAnyOrder(
+ tuple("registered", "notify"),
+ tuple("registered", "crm"));
+ }
+
+ @Test
+ void testUntaggedEndpointFiresDefaultPoolEvent() throws Exception {
+ context.addRoutes(new RouteBuilder() {
+ public void configure() {
+ from("ai-tool:lookupOrder?description=Look up an order")
+ .routeId("order-route")
+ .setBody(constant("order"));
+ }
+ });
+
+ assertThat(listener.events)
+ .filteredOn(e -> "lookupOrder".equals(e.toolName()))
+ .extracting(Event::type, Event::tag)
+ .containsExactly(tuple("registered", null));
+
+ context.getRouteController().stopRoute("order-route");
+
+ assertThat(listener.events)
+ .filteredOn(e -> "lookupOrder".equals(e.toolName()))
+ .extracting(Event::type, Event::tag)
+ .containsExactly(
+ tuple("registered", null),
+ tuple("deregistered", null));
+ }
+
+ private record Event(String type, String tag, String toolName) {
+ }
+
+ private static final class RecordingListener implements
AiToolRegistryListener {
+ private final List<Event> events = new CopyOnWriteArrayList<>();
+
+ @Override
+ public void toolRegistered(String tag, AiToolSpec spec) {
+ events.add(new Event("registered", tag, spec.getName()));
+ }
+
+ @Override
+ public void toolDeregistered(String tag, AiToolSpec spec) {
+ events.add(new Event("deregistered", tag, spec.getName()));
+ }
+ }
+}
diff --git
a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryListenerTest.java
b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryListenerTest.java
new file mode 100644
index 000000000000..3d1c5e009acf
--- /dev/null
+++
b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryListenerTest.java
@@ -0,0 +1,158 @@
+/*
+ * 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.component.ai.tool;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class AiToolRegistryListenerTest {
+
+ private AiToolRegistry registry;
+ private RecordingListener listener;
+
+ @BeforeEach
+ void setUp() {
+ registry = new AiToolRegistry();
+ listener = new RecordingListener();
+ registry.addListener(listener);
+ }
+
+ @Test
+ void testRegisteredEventOnPut() {
+ AiToolSpec spec = spec("getWeather");
+ registry.put("weather", spec);
+
+ assertThat(listener.events).containsExactly(new Event("registered",
"weather", spec));
+ }
+
+ @Test
+ void testNoDuplicateEventOnRepeatedPutOfSameSpec() {
+ AiToolSpec spec = spec("getWeather");
+ registry.put("weather", spec);
+ registry.put("weather", spec);
+
+ assertThat(listener.events)
+ .as("Re-adding the same spec instance should not fire a second
event")
+ .hasSize(1);
+ }
+
+ @Test
+ void testDeregisteredEventOnRemove() {
+ AiToolSpec spec = spec("getWeather");
+ registry.put("weather", spec);
+ registry.remove("weather", spec);
+
+ assertThat(listener.events).containsExactly(
+ new Event("registered", "weather", spec),
+ new Event("deregistered", "weather", spec));
+ }
+
+ @Test
+ void testNoEventOnRemovingAbsentSpec() {
+ registry.remove("weather", spec("getWeather"));
+ registry.removeDefault(spec("getWeather"));
+
+ assertThat(listener.events)
+ .as("Removing a spec that was never registered should not fire
events")
+ .isEmpty();
+ }
+
+ @Test
+ void testDefaultPoolEventsUseNullTag() {
+ AiToolSpec spec = spec("getWeather");
+ registry.putDefault(spec);
+ registry.removeDefault(spec);
+
+ assertThat(listener.events).containsExactly(
+ new Event("registered", null, spec),
+ new Event("deregistered", null, spec));
+ }
+
+ @Test
+ void testNoEventWhenPutThrowsOnDuplicateName() {
+ registry.put("weather", spec("getWeather"));
+ listener.events.clear();
+
+ assertThatThrownBy(() -> registry.put("weather", spec("getWeather")))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThat(listener.events)
+ .as("A rejected registration should not fire an event")
+ .isEmpty();
+ }
+
+ @Test
+ void testListenerExceptionDoesNotBreakRegistrationOrOtherListeners() {
+ registry.addListener(new AiToolRegistryListener() {
+ @Override
+ public void toolRegistered(String tag, AiToolSpec spec) {
+ throw new IllegalStateException("boom");
+ }
+
+ @Override
+ public void toolDeregistered(String tag, AiToolSpec spec) {
+ throw new IllegalStateException("boom");
+ }
+ });
+ RecordingListener second = new RecordingListener();
+ registry.addListener(second);
+
+ AiToolSpec spec = spec("getWeather");
+ registry.put("weather", spec);
+
+ assertThat(registry.getToolsByTag("weather"))
+ .as("Registration should succeed despite a failing listener")
+ .contains(spec);
+ assertThat(second.events)
+ .as("Listeners after the failing one should still be notified")
+ .containsExactly(new Event("registered", "weather", spec));
+ }
+
+ @Test
+ void testRemovedListenerReceivesNoFurtherEvents() {
+ registry.removeListener(listener);
+ registry.put("weather", spec("getWeather"));
+
+ assertThat(listener.events).isEmpty();
+ }
+
+ private static AiToolSpec spec(String name) {
+ return new AiToolSpec(name, name + " description", null, null, null);
+ }
+
+ private record Event(String type, String tag, AiToolSpec spec) {
+ }
+
+ private static final class RecordingListener implements
AiToolRegistryListener {
+ private final List<Event> events = new ArrayList<>();
+
+ @Override
+ public void toolRegistered(String tag, AiToolSpec spec) {
+ events.add(new Event("registered", tag, spec));
+ }
+
+ @Override
+ public void toolDeregistered(String tag, AiToolSpec spec) {
+ events.add(new Event("deregistered", tag, spec));
+ }
+ }
+}