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 6589e123eb0f CAMEL-25307: camel-jbang-mcp - an HTTP tool group: served 
endpoints and a request tool for the selected integration (#27330)
6589e123eb0f is described below

commit 6589e123eb0f2baedd6d673e76bee8239906345b
Author: Claus Ibsen <[email protected]>
AuthorDate: Sun Oct 4 17:02:12 2026 +0200

    CAMEL-25307: camel-jbang-mcp - an HTTP tool group: served endpoints and a 
request tool for the selected integration (#27330)
    
    * CAMEL-25307: camel-jbang - an HTTP tool group: served endpoints and a 
request tool for the selected integration
    
    A local model building an HTTP API cannot see what its running integration 
serves nor call it. The status of the
    integration (the rests and platform-http sections, HTTP consumer endpoints) 
now loads an http tool group:
    
    - shared registry: get_http_endpoints (method, path, consumes/produces, 
route, OpenAPI operation, base URL,
      contract; includeSpec adds the contract, size-capped) and http_request 
(only to the integration's own server
      on localhost and its port, no redirects, body cut at 6000 characters)
    - camel-jbang-mcp: camel_runtime_http_endpoints and 
camel_runtime_http_request
    - AppFeatures, ToolGroup.HTTP and ToolGroups: the group with its tools and 
a guidance line naming the base URL and
      the contract; the fingerprint includes the base URL
    - AI panel in camel-jbang views: tui_http_endpoints and tui_http_request, 
loaded by the group (not core)
    - docs for the tools and the group
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    * CAMEL-25307: review - one shared HttpClient for the request tool, 
nanoTime for the elapsed time
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../modules/ROOT/pages/camel-jbang-mcp.adoc        |  18 +-
 .../ROOT/pages/camel-jbang-tui-local-models.adoc   |   5 +-
 .../dsl/jbang/core/commands/ai/AppFeatures.java    |  40 +-
 .../dsl/jbang/core/commands/ai/HttpEndpoints.java  | 539 +++++++++++++++++++++
 .../dsl/jbang/core/commands/ai/ToolGroup.java      |   4 +-
 .../dsl/jbang/core/commands/ai/ToolGroups.java     |  47 +-
 .../dsl/jbang/core/commands/ai/ToolRegistry.java   |  64 ++-
 .../jbang/core/commands/ai/AppFeaturesTest.java    |  48 ++
 .../jbang/core/commands/ai/HttpEndpointsTest.java  | 153 ++++++
 .../dsl/jbang/core/commands/ai/ToolGroupsTest.java |  30 ++
 .../jbang/core/commands/ai/ToolRegistryTest.java   |  16 +
 .../dsl/jbang/core/commands/mcp/RuntimeTools.java  |  42 ++
 .../jbang/core/commands/mcp/RuntimeToolsTest.java  |  35 +-
 .../core/commands/tui/TuiToolDefinitions.java      |  15 +
 .../dsl/jbang/core/commands/tui/TuiToolGroups.java |  19 +-
 .../jbang/core/commands/tui/TuiToolRegistry.java   |   9 +-
 .../core/commands/tui/AiPanelPromptBudgetTest.java |  18 +-
 .../core/commands/tui/AiPanelToolGroupsTest.java   |  26 +-
 18 files changed, 1085 insertions(+), 43 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 1586b3770178..609b1a3658bd 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -798,8 +798,18 @@ process is running).
   the update count otherwise. Lets the model look at the data a route reads or 
writes, or try a statement
   before putting it in a route.
 
+| `camel_runtime_http_endpoints`
+| The HTTP endpoints the application serves (Rest DSL and platform-http): 
method, path, consumes/produces, route
+  and OpenAPI operation, the server's base URL and the contract. With 
`includeSpec=true` the OpenAPI contract
+  itself is added (cut at 12000 characters).
+
+| `camel_runtime_http_request`
+| Sends an HTTP request (method, path, headers, body) to the application's own 
server on localhost and its port,
+  and returns the status, the headers and the body (cut at 6000 characters, 
the answer says when). Any other host
+  is refused, and redirects are not followed.
+
 | `camel_runtime_tool_groups`
-| Which runtime tool groups (`sql`, `tracing`, `resilience`) the application 
needs, from what its status shows.
+| Which runtime tool groups (`sql`, `tracing`, `resilience`, `http`) the 
application needs, from what its status shows.
   See <<_tool_groups_for_local_models>>.
 
 | `camel_runtime_datasources`
@@ -852,13 +862,17 @@ runtime tool, it can offer the core tools plus the groups 
the selected applicati
 | `resilience`
 | a circuit breaker in a route, or circuit breakers in the Resilience4j or 
Fault Tolerance status
 | `camel_runtime_circuit_breakers`
+
+| `http`
+| Rest DSL services or platform-http endpoints, or a `platform-http`, `rest` 
or `rest-openapi` consumer
+| `camel_runtime_http_endpoints`, `camel_runtime_http_request`
 |===
 
 The answer names the application and its pid, lists the `core` tools (the 
shared `camel_*` tools a small model
 should always get), each loaded group with its tools and one line of guidance 
for the system prompt (for example
 `SQL: datasource(s) orders (HikariCP), used by sql endpoints. Table names come 
from the SQL trace
 (camel_runtime_sql_trace); don't guess a schema.`), and the `signals` in the 
status that loaded each group. Its
-`fingerprint` stays the same as long as the groups and the datasources do,
+`fingerprint` stays the same as long as the groups, the datasources and the 
HTTP base URL do,
 so a client calls the tool when the user selects an application or the 
application reloads, and rebuilds its
 tool list and prompt only when the fingerprint changes; the prompt then stays 
the same and a local model keeps
 its prompt cache. A status written by an older Camel version may lack some of 
the keys, which just loads fewer
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-local-models.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-local-models.adoc
index acf8103abed2..1f742482cb0d 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-local-models.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-local-models.adoc
@@ -72,13 +72,16 @@ from the *SQL Trace* tab
 | a line pointing at `tui_get_spans`, `tui_get_history` and the *Metrics* tab, 
which are core tools already
 | `resilience` | a circuit breaker in a route, or circuit breakers in the 
Resilience4j or Fault Tolerance status
 | a line naming the routes with a circuit breaker and pointing at the *Circuit 
Breaker* tab
+| `http` | Rest DSL services or platform-http endpoints, or a `platform-http`, 
`rest` or `rest-openapi` consumer
+| `tui_http_endpoints` (the served operations, optionally the OpenAPI 
contract) and `tui_http_request` (a request to
+the integration's own server on localhost, no other host), and a line with the 
base URL and the contract
 |===
 
 Each loaded group adds one line at the end of the system prompt. The groups 
are read again only when you
 select another integration or the selected one reloads its routes (after a 
reload the groups only grow), so
 the tools and the prompt stay the same from question to question and Ollama 
keeps reusing the cached prompt.
 An integration without any group yet is read again on each question, since one 
that just started may not
-have written its status completely. With all three groups the static prefix 
grows by about 520 tokens.
+have written its status completely. With all four groups the static prefix 
grows by about 800 tokens.
 
 The full tool set (`/tools full` and hosted providers) is not affected by the 
groups. `/tools` and `/context`
 show the loaded groups.
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AppFeatures.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AppFeatures.java
index f72822e8774b..4a4d18ed7937 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AppFeatures.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AppFeatures.java
@@ -29,9 +29,9 @@ import org.apache.camel.util.json.JsonObject;
 
 /**
  * What a running integration has that a tool group is for (CAMEL-24834): its 
datasources and SQL endpoints, circuit
- * breakers, OpenTelemetry, message tracing and Micrometer. Read from the 
status file the integration writes
- * ({@code ~/.camel/<pid>-status.json}); a key that is missing (an older 
Camel, a console that is not on the classpath)
- * just means fewer features, never an error.
+ * breakers, OpenTelemetry, message tracing and Micrometer, and what it serves 
over HTTP (CAMEL-25307). Read from the
+ * status file the integration writes ({@code ~/.camel/<pid>-status.json}); a 
key that is missing (an older Camel, a
+ * console that is not on the classpath) just means fewer features, never an 
error.
  *
  * @param dataSources          the datasources in the registry, by name
  * @param sqlComponents        the SQL components the endpoints and routes use 
(sql, sql-stored, jdbc, spring-jdbc, jpa)
@@ -42,6 +42,7 @@ import org.apache.camel.util.json.JsonObject;
  * @param messageTracing       whether message tracing is enabled
  * @param micrometer           whether Micrometer metrics are on
  * @param signals              the status keys that gave each feature away, 
with what they said
+ * @param http                 what it serves over HTTP, null when nothing
  */
 public record AppFeatures(
         List<DataSource> dataSources,
@@ -52,7 +53,8 @@ public record AppFeatures(
         boolean openTelemetry,
         boolean messageTracing,
         boolean micrometer,
-        Map<String, String> signals) {
+        Map<String, String> signals,
+        HttpEndpoints.Served http) {
 
     /** The components whose endpoints talk SQL to a datasource. */
     static final Set<String> SQL_COMPONENTS = Set.of("sql", "sql-stored", 
"jdbc", "spring-jdbc", "jpa");
@@ -77,11 +79,31 @@ public record AppFeatures(
         signals = Collections.unmodifiableMap(new LinkedHashMap<>(signals));
     }
 
+    /** The features of an integration that serves nothing over HTTP. */
+    public AppFeatures(List<DataSource> dataSources, List<String> 
sqlComponents, boolean sqlTraced,
+                       boolean circuitBreaker, List<String> 
circuitBreakerRoutes, boolean openTelemetry,
+                       boolean messageTracing, boolean micrometer, Map<String, 
String> signals) {
+        this(dataSources, sqlComponents, sqlTraced, circuitBreaker, 
circuitBreakerRoutes, openTelemetry, messageTracing,
+             micrometer, signals, null);
+    }
+
     /** No integration, or nothing to tell: no tool group loads. */
     public static AppFeatures none() {
         return new AppFeatures(List.of(), List.of(), false, false, List.of(), 
false, false, false, Map.of());
     }
 
+    /** Whether the integration serves HTTP: platform-http or Rest DSL 
endpoints. */
+    public boolean httpServed() {
+        return http != null;
+    }
+
+    /** These features with what the integration serves over HTTP. */
+    public AppFeatures withHttp(HttpEndpoints.Served served) {
+        return new AppFeatures(
+                dataSources, sqlComponents, sqlTraced, circuitBreaker, 
circuitBreakerRoutes,
+                openTelemetry, messageTracing, micrometer, signals, served);
+    }
+
     /** The names of the datasources. */
     public List<String> dataSourceNames() {
         return dataSources.stream().map(DataSource::name).toList();
@@ -123,7 +145,7 @@ public record AppFeatures(
                 new ArrayList<>(ds.values()), new ArrayList<>(components), 
sqlTraced || other.sqlTraced,
                 circuitBreaker || other.circuitBreaker, new 
ArrayList<>(routes),
                 openTelemetry || other.openTelemetry, messageTracing || 
other.messageTracing,
-                micrometer || other.micrometer, sig);
+                micrometer || other.micrometer, sig, other.http != null ? 
other.http : http);
     }
 
     /**
@@ -238,9 +260,15 @@ public record AppFeatures(
             signals.put("trace.enabled", "true");
         }
 
+        // HTTP: the Rest DSL and platform-http consoles, or HTTP consumers in 
the endpoints and routes
+        HttpEndpoints.Served http = HttpEndpoints.fromStatus(status);
+        if (http != null) {
+            http.signals().forEach(signals::putIfAbsent);
+        }
+
         return new AppFeatures(
                 new ArrayList<>(dataSources.values()), new 
ArrayList<>(components), sqlTraced,
-                breakerProcessor || breakers, new ArrayList<>(breakerRoutes), 
otel, tracing, micrometer, signals);
+                breakerProcessor || breakers, new ArrayList<>(breakerRoutes), 
otel, tracing, micrometer, signals, http);
     }
 
     private static void addSqlComponent(Set<String> components, String uri) {
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HttpEndpoints.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HttpEndpoints.java
new file mode 100644
index 000000000000..65589b3cddde
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/HttpEndpoints.java
@@ -0,0 +1,539 @@
+/*
+ * 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.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+/**
+ * What a running integration serves over HTTP (CAMEL-25307), read from its 
status: the Rest DSL services
+ * ({@code rests}) and the platform-http endpoints ({@code platform-http}), 
the port of the embedded server and the
+ * OpenAPI contract of a contract-first service. Pure and defensive like 
{@link AppFeatures}.
+ */
+public final class HttpEndpoints {
+
+    /** How much of a response body http_request returns; a small model's 
context is the limit. */
+    static final int MAX_BODY_CHARS = 6000;
+
+    /** How much of each OpenAPI contract get_http_endpoints returns with 
includeSpec. */
+    static final int MAX_SPEC_CHARS = 12000;
+
+    private static final Set<String> LOCAL_HOSTS = Set.of("localhost", 
"127.0.0.1", "0.0.0.0", "::1", "[::1]");
+
+    /** The components whose consumers serve HTTP. */
+    static final Set<String> HTTP_COMPONENTS = Set.of("platform-http", "rest", 
"rest-openapi");
+
+    /**
+     * One served operation.
+     *
+     * @param method        the HTTP method, ANY when the endpoint takes every 
method
+     * @param path          the path, with {placeholders}
+     * @param consumes      the accepted media types, null when not known
+     * @param produces      the returned media types, null when not known
+     * @param routeId       the route, null when not known
+     * @param operationId   the OpenAPI operation, null when not contract-first
+     * @param source        rest (Rest DSL) or platform-http
+     * @param specification whether this is the api-doc endpoint that serves 
the contract itself
+     */
+    public record Endpoint(
+            String method, String path, String consumes, String produces, 
String routeId, String operationId,
+            String source, boolean specification) {
+    }
+
+    /**
+     * What the integration serves.
+     *
+     * @param port      the port of the embedded server, 0 when not known
+     * @param basePath  the path all operations share, empty when none
+     * @param contract  the file name of the OpenAPI contract, null when not 
contract-first
+     * @param endpoints the operations, Rest DSL first
+     * @param signals   the status keys that told, with what they said
+     */
+    public record Served(int port, String basePath, String contract, 
List<Endpoint> endpoints,
+            Map<String, String> signals) {
+
+        public Served {
+            endpoints = List.copyOf(endpoints);
+            signals = Collections.unmodifiableMap(new 
LinkedHashMap<>(signals));
+        }
+
+        /** {@code http://localhost:<port><basePath>}, null when the port is 
not known. */
+        public String baseUrl() {
+            return port > 0 ? "http://localhost:"; + port + basePath : null;
+        }
+    }
+
+    private HttpEndpoints() {
+    }
+
+    /** What the status tells about HTTP, or null when the integration serves 
nothing over HTTP. */
+    public static Served fromStatus(Map<?, ?> status) {
+        if (status == null) {
+            return null;
+        }
+        Map<String, String> signals = new LinkedHashMap<>();
+        List<Endpoint> endpoints = new ArrayList<>();
+        Set<String> seen = new LinkedHashSet<>();
+        int port = 0;
+        String contract = null;
+
+        // Rest DSL services: rests.rests[] with url, method, consumes, 
produces, routeId, operationId
+        List<Map<?, ?>> rests = objects(section(status, "rests").get("rests"));
+        for (Map<?, ?> r : rests) {
+            String url = text(r.get("url"));
+            String path = pathOf(url);
+            String method = upper(text(r.get("method")));
+            if (path == null) {
+                continue;
+            }
+            if (port == 0) {
+                port = portOf(url);
+            }
+            if (contract == null) {
+                contract = fileName(text(r.get("specificationUri")));
+            }
+            endpoints.add(new Endpoint(
+                    method != null ? method : "ANY", path, 
text(r.get("consumes")), text(r.get("produces")),
+                    text(r.get("routeId")), text(r.get("operationId")), "rest",
+                    Boolean.TRUE.equals(r.get("specification"))));
+            seen.add((method != null ? method : "ANY") + " " + path);
+        }
+        if (!rests.isEmpty()) {
+            signals.put("rests", rests.size() + " service(s)");
+        }
+
+        // platform-http: the server URL and the endpoints, one per verb; the 
Rest DSL ones are already listed
+        Map<?, ?> php = section(status, "platform-http");
+        String server = text(php.get("server"));
+        int serverPort = portOf(server);
+        if (serverPort > 0) {
+            // the embedded server knows the port; a Rest DSL URL may carry a 
configured one instead
+            port = serverPort;
+        }
+        List<Map<?, ?>> phpEndpoints = objects(php.get("endpoints"));
+        for (Map<?, ?> e : phpEndpoints) {
+            String path = text(e.get("path"));
+            if (path == null) {
+                path = pathOf(text(e.get("url")));
+            }
+            if (path == null) {
+                continue;
+            }
+            if (!path.startsWith("/")) {
+                path = "/" + path;
+            }
+            if (port == 0) {
+                port = portOf(text(e.get("url")));
+            }
+            String verbs = text(e.get("verbs"));
+            List<String> methods = new ArrayList<>();
+            if (verbs != null) {
+                for (String v : verbs.split(",")) {
+                    if (!v.isBlank()) {
+                        methods.add(upper(v.trim()));
+                    }
+                }
+            }
+            if (methods.isEmpty()) {
+                methods.add("ANY");
+            }
+            for (String m : methods) {
+                if (seen.add(m + " " + path)) {
+                    endpoints.add(new Endpoint(
+                            m, path, text(e.get("consumes")), 
text(e.get("produces")), null, null,
+                            "platform-http", false));
+                }
+            }
+        }
+        if (server != null || !phpEndpoints.isEmpty()) {
+            signals.put("platform-http", server != null ? server : 
phpEndpoints.size() + " endpoint(s)");
+        }
+
+        // HTTP consumers in the endpoint registry and the route inputs, for a 
status without the consoles above
+        Set<String> schemes = new LinkedHashSet<>();
+        List<String> uris = new ArrayList<>();
+        for (Map<?, ?> ep : objects(section(status, 
"endpoints").get("endpoints"))) {
+            uris.add(text(ep.get("uri")));
+        }
+        for (Map<?, ?> route : objects(status.get("routes"))) {
+            uris.add(text(route.get("from")));
+        }
+        for (String uri : uris) {
+            String scheme = schemeOf(uri);
+            if (scheme != null && HTTP_COMPONENTS.contains(scheme)) {
+                schemes.add(scheme);
+                if (contract == null && "rest-openapi".equals(scheme)) {
+                    contract = fileName(uri.substring(scheme.length() + 1));
+                }
+            }
+        }
+        if (!schemes.isEmpty()) {
+            signals.put("endpoints.http", String.join(",", schemes));
+        }
+        if (contract != null) {
+            signals.put("contract", contract);
+        }
+        if (signals.isEmpty()) {
+            return null;
+        }
+        return new Served(port, basePath(endpoints), contract, endpoints, 
signals);
+    }
+
+    /** The answer of get_http_endpoints: the server, the contract and the 
operations. */
+    public static JsonObject toJson(Served served) {
+        JsonObject answer = new JsonObject();
+        if (served == null) {
+            answer.put("endpoints", new JsonArray());
+            answer.put("hint", "The integration serves nothing over HTTP (no 
platform-http or Rest DSL endpoints)");
+            return answer;
+        }
+        JsonObject server = new JsonObject();
+        server.put("baseUrl", served.baseUrl());
+        server.put("port", served.port() > 0 ? served.port() : null);
+        server.put("basePath", served.basePath());
+        answer.put("server", server);
+        if (served.contract() != null) {
+            answer.put("contract", served.contract());
+        }
+        JsonArray list = new JsonArray();
+        for (Endpoint e : served.endpoints()) {
+            JsonObject jo = new JsonObject();
+            jo.put("method", e.method());
+            jo.put("path", e.path());
+            putIfNotNull(jo, "consumes", e.consumes());
+            putIfNotNull(jo, "produces", e.produces());
+            putIfNotNull(jo, "routeId", e.routeId());
+            putIfNotNull(jo, "operationId", e.operationId());
+            jo.put("source", e.source());
+            if (e.specification()) {
+                jo.put("specification", true);
+            }
+            list.add(jo);
+        }
+        answer.put("endpoints", list);
+        return answer;
+    }
+
+    /**
+     * The URL a request goes to: the path on the integration's own server. An 
absolute URL is accepted only when it
+     * points at that server (localhost and its port), so the tool cannot be 
used to reach anything else.
+     *
+     * @throws ToolExecutionException when the port is not known or the URL 
points elsewhere
+     */
+    static URI requestUri(int port, String path) {
+        if (path == null || path.isBlank()) {
+            throw new ToolExecutionException("path is required, e.g. 
/api/orders/1");
+        }
+        if (port <= 0) {
+            throw new ToolExecutionException(
+                    "The integration's HTTP port is not known (no 
platform-http server in its status yet)");
+        }
+        String p = path.trim();
+        if (p.startsWith("//") || p.contains("://")) {
+            URI given;
+            try {
+                given = URI.create(p);
+            } catch (IllegalArgumentException e) {
+                throw new ToolExecutionException("Not a valid URL: " + p);
+            }
+            String host = given.getHost();
+            if (!"http".equalsIgnoreCase(given.getScheme()) || host == null
+                    || !LOCAL_HOSTS.contains(host.toLowerCase(Locale.ROOT)) || 
given.getPort() != port) {
+                throw new ToolExecutionException(
+                        "Only the integration's own server can be called: pass 
a path such as /api/orders, it goes to "
+                                                 + "http://localhost:"; + port);
+            }
+            p = given.getRawPath() == null || given.getRawPath().isEmpty() ? 
"/" : given.getRawPath();
+            if (given.getRawQuery() != null) {
+                p += "?" + given.getRawQuery();
+            }
+        }
+        if (!p.startsWith("/")) {
+            p = "/" + p;
+        }
+        try {
+            return URI.create("http://localhost:"; + port + p.replace(" ", 
"%20"));
+        } catch (IllegalArgumentException e) {
+            throw new ToolExecutionException("Not a valid path: " + p + " (" + 
e.getMessage() + ")");
+        }
+    }
+
+    /**
+     * One client for every request: a client per call would leave its 
executor threads and connections behind until the
+     * next GC (a model calls this dozens of times in a session), and 
HttpClient cannot be closed before Java 21. The
+     * per-request timeout is on the request.
+     */
+    private static final class Client {
+        static final HttpClient INSTANCE = HttpClient.newBuilder()
+                .followRedirects(HttpClient.Redirect.NEVER)
+                .connectTimeout(Duration.ofSeconds(10))
+                .build();
+    }
+
+    /**
+     * Sends a request to the integration's own server and returns the status, 
the headers and the body (at most
+     * {@value #MAX_BODY_CHARS} characters, the answer says when it was cut). 
Redirects are not followed, the answer
+     * shows the Location header instead.
+     */
+    static JsonObject request(int port, String method, String path, String 
headers, String body, int timeoutSeconds) {
+        URI uri = requestUri(port, path);
+        String m = method == null || method.isBlank() ? "GET" : 
method.trim().toUpperCase(Locale.ROOT);
+        int timeout = timeoutSeconds > 0 ? Math.min(timeoutSeconds, 120) : 30;
+        HttpRequest.Builder builder = 
HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(timeout));
+        parseHeaders(headers).forEach((k, v) -> {
+            try {
+                builder.header(k, v);
+            } catch (IllegalArgumentException e) {
+                throw new ToolExecutionException("Header " + k + " cannot be 
set: " + e.getMessage());
+            }
+        });
+        builder.method(m, body == null || body.isEmpty()
+                ? HttpRequest.BodyPublishers.noBody()
+                : HttpRequest.BodyPublishers.ofString(body, 
StandardCharsets.UTF_8));
+        long start = System.nanoTime();
+        HttpResponse<String> response;
+        try {
+            response = Client.INSTANCE.send(builder.build(), 
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+        } catch (IOException e) {
+            throw new ToolExecutionException(
+                    m + " " + uri + " failed: " + e.getClass().getSimpleName()
+                                             + (e.getMessage() != null ? ": " 
+ e.getMessage() : ""));
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new ToolExecutionException(m + " " + uri + " was 
interrupted");
+        }
+        JsonObject answer = new JsonObject();
+        answer.put("request", m + " " + uri);
+        answer.put("status", response.statusCode());
+        answer.put("elapsedMs", (System.nanoTime() - start) / 1_000_000);
+        JsonObject responseHeaders = new JsonObject();
+        response.headers().map().forEach((k, v) -> responseHeaders.put(k, 
String.join(", ", v)));
+        answer.put("headers", responseHeaders);
+        String text = response.body() != null ? response.body() : "";
+        if (text.length() > MAX_BODY_CHARS) {
+            answer.put("body", text.substring(0, MAX_BODY_CHARS));
+            answer.put("truncated", "the body has " + text.length() + " 
characters, the first " + MAX_BODY_CHARS
+                                    + " are shown");
+        } else {
+            answer.put("body", text);
+        }
+        return answer;
+    }
+
+    /** Headers as a JSON object, or one {@code name: value} (or {@code 
name=value}) per line. */
+    static Map<String, String> parseHeaders(String headers) {
+        Map<String, String> answer = new LinkedHashMap<>();
+        if (headers == null || headers.isBlank()) {
+            return answer;
+        }
+        String h = headers.trim();
+        if (h.startsWith("{")) {
+            try {
+                if (Jsoner.deserialize(h) instanceof Map<?, ?> map) {
+                    map.forEach((k, v) -> {
+                        if (k != null && v != null) {
+                            answer.put(k.toString(), v.toString());
+                        }
+                    });
+                    return answer;
+                }
+            } catch (Exception e) {
+                throw new ToolExecutionException("headers is not a valid JSON 
object: " + e.getMessage());
+            }
+        }
+        for (String line : h.split("\\r?\\n")) {
+            int idx = indexOfAny(line, ':', '=');
+            if (idx > 0) {
+                answer.put(line.substring(0, idx).trim(), line.substring(idx + 
1).trim());
+            }
+        }
+        return answer;
+    }
+
+    /** The rest-spec action answer with each contract cut to {@value 
#MAX_SPEC_CHARS} characters. */
+    static Object capSpecs(String restSpec) {
+        try {
+            if (Jsoner.deserialize(restSpec) instanceof JsonObject jo && 
jo.get("specs") instanceof List<?> specs) {
+                for (Object o : specs) {
+                    if (o instanceof JsonObject spec && spec.get("content") 
instanceof String content
+                            && content.length() > MAX_SPEC_CHARS) {
+                        spec.put("content", content.substring(0, 
MAX_SPEC_CHARS));
+                        spec.put("truncated", "the contract has " + 
content.length() + " characters, the first "
+                                              + MAX_SPEC_CHARS + " are shown");
+                    }
+                }
+                return jo;
+            }
+        } catch (Exception e) {
+            // not JSON: return as it came
+        }
+        return restSpec;
+    }
+
+    /**
+     * The path all operations share, by whole segments and before the first 
placeholder: {@code /api} for
+     * {@code /api/stock} and {@code /api/stock/{id}}; empty for a single 
operation directly under the root.
+     */
+    static String basePath(List<Endpoint> endpoints) {
+        List<String> common = null;
+        for (Endpoint e : endpoints) {
+            if (e.specification()) {
+                continue;
+            }
+            List<String> segments = new ArrayList<>();
+            for (String s : e.path().split("/")) {
+                if (s.isEmpty()) {
+                    continue;
+                }
+                if (s.contains("{")) {
+                    break;
+                }
+                segments.add(s);
+            }
+            // the last segment names the operation, not the base
+            if (!segments.isEmpty() && segments.size() == 
countSegments(e.path())) {
+                segments.remove(segments.size() - 1);
+            }
+            if (common == null) {
+                common = segments;
+            } else {
+                int i = 0;
+                while (i < common.size() && i < segments.size() && 
common.get(i).equals(segments.get(i))) {
+                    i++;
+                }
+                common = new ArrayList<>(common.subList(0, i));
+            }
+        }
+        return common == null || common.isEmpty() ? "" : "/" + 
String.join("/", common);
+    }
+
+    private static int countSegments(String path) {
+        int n = 0;
+        for (String s : path.split("/")) {
+            if (!s.isEmpty()) {
+                n++;
+            }
+        }
+        return n;
+    }
+
+    /** The port of a URL, 0 when it has none. */
+    static int portOf(String url) {
+        if (url == null) {
+            return 0;
+        }
+        try {
+            int port = URI.create(url.replace("{", "%7B").replace("}", 
"%7D")).getPort();
+            return Math.max(port, 0);
+        } catch (IllegalArgumentException e) {
+            return 0;
+        }
+    }
+
+    /** The path of a URL, or the text itself when it is a path already. */
+    static String pathOf(String url) {
+        if (url == null) {
+            return null;
+        }
+        int idx = url.indexOf("://");
+        if (idx < 0) {
+            return url.startsWith("/") ? url : "/" + url;
+        }
+        int slash = url.indexOf('/', idx + 3);
+        return slash >= 0 ? url.substring(slash) : "/";
+    }
+
+    /** The file name of a contract location: {@code stock-api.json} for 
{@code classpath:api/stock-api.json?x=y}. */
+    static String fileName(String location) {
+        if (location == null) {
+            return null;
+        }
+        String s = location;
+        int cut = indexOfAny(s, '?', '#');
+        if (cut >= 0) {
+            s = s.substring(0, cut);
+        }
+        int slash = Math.max(s.lastIndexOf('/'), s.lastIndexOf(':'));
+        s = s.substring(slash + 1);
+        return s.isBlank() ? null : s;
+    }
+
+    private static int indexOfAny(String s, char a, char b) {
+        int i = s.indexOf(a);
+        int j = s.indexOf(b);
+        return i < 0 ? j : j < 0 ? i : Math.min(i, j);
+    }
+
+    private static String schemeOf(String uri) {
+        if (uri == null) {
+            return null;
+        }
+        int colon = uri.indexOf(':');
+        return colon > 0 ? uri.substring(0, colon) : null;
+    }
+
+    private static String upper(String s) {
+        return s != null ? s.toUpperCase(Locale.ROOT) : null;
+    }
+
+    private static void putIfNotNull(JsonObject jo, String key, Object value) {
+        if (value != null) {
+            jo.put(key, value);
+        }
+    }
+
+    private static Map<?, ?> section(Map<?, ?> root, String key) {
+        return root.get(key) instanceof Map<?, ?> m ? m : Map.of();
+    }
+
+    private static List<Map<?, ?>> objects(Object value) {
+        List<Map<?, ?>> answer = new ArrayList<>();
+        if (value instanceof List<?> l) {
+            for (Object o : l) {
+                if (o instanceof Map<?, ?> m) {
+                    answer.add(m);
+                }
+            }
+        }
+        return answer;
+    }
+
+    private static String text(Object value) {
+        if (value == null) {
+            return null;
+        }
+        String s = value.toString();
+        return s.isBlank() ? null : s;
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroup.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroup.java
index 674fae926ac1..c6a0682ab641 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroup.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroup.java
@@ -27,7 +27,9 @@ public enum ToolGroup {
     /** OpenTelemetry spans, message tracing and Micrometer metrics. */
     TRACING("tracing"),
     /** Circuit breakers. */
-    RESILIENCE("resilience");
+    RESILIENCE("resilience"),
+    /** The HTTP endpoints the integration serves, and a request to them 
(CAMEL-25307). */
+    HTTP("http");
 
     private final String id;
 
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroups.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroups.java
index ea1983dd63f4..a4c789f77a5e 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroups.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroups.java
@@ -22,10 +22,10 @@ import java.util.stream.Collectors;
 
 /**
  * Picks the runtime tool groups for an integration from its {@link 
AppFeatures} (CAMEL-24834). A small local model pays
- * for every tool schema on every request, so the SQL, tracing and resilience 
tools only load when the integration has a
- * database, tracing or circuit breakers; each loaded group adds one line of 
guidance that names what the integration
- * has and which tool reads it. The tool names and the wording here are those 
of the MCP server; the TUI maps the same
- * groups to its own tools.
+ * for every tool schema on every request, so the SQL, tracing, resilience and 
HTTP tools only load when the integration
+ * has a database, tracing, circuit breakers or HTTP endpoints; each loaded 
group adds one line of guidance that names
+ * what the integration has and which tool reads it. The tool names and the 
wording here are those of the MCP server;
+ * the TUI maps the same groups to its own tools.
  */
 public final class ToolGroups {
 
@@ -36,6 +36,8 @@ public final class ToolGroups {
     public static final String TRACE_TOOL = "camel_runtime_trace";
     public static final String METRICS_TOOL = "camel_runtime_metrics";
     public static final String CIRCUIT_BREAKERS_TOOL = 
"camel_runtime_circuit_breakers";
+    public static final String HTTP_ENDPOINTS_TOOL = 
"camel_runtime_http_endpoints";
+    public static final String HTTP_REQUEST_TOOL = 
"camel_runtime_http_request";
 
     /**
      * A loaded group.
@@ -55,8 +57,8 @@ public final class ToolGroups {
      * The groups for an integration.
      *
      * @param groups      the loaded groups, in {@link ToolGroup} order
-     * @param fingerprint stable for the same groups and datasources: a client 
rebuilds its tool list only when it
-     *                    changes
+     * @param fingerprint stable for the same groups, datasources and HTTP 
base URL: a client rebuilds its tool list
+     *                    only when it changes
      */
     public record Selection(List<Group> groups, String fingerprint) {
 
@@ -119,6 +121,10 @@ public final class ToolGroups {
                         describeBreakers(f) + ": " + CIRCUIT_BREAKERS_TOOL
                                                                + " shows state 
(CLOSED/OPEN/HALF_OPEN) and failure rate;"
                                                                + " OPEN means 
the fallback runs."));
+                case HTTP -> groups.add(new Group(
+                        group, List.of(HTTP_ENDPOINTS_TOOL, HTTP_REQUEST_TOOL),
+                        "HTTP: " + describeHttp(f) + "; " + HTTP_REQUEST_TOOL 
+ " calls it, " + HTTP_ENDPOINTS_TOOL
+                                                                               
 + " lists the operations."));
             }
         }
         return new Selection(groups, fingerprint(groups(f), f));
@@ -136,17 +142,40 @@ public final class ToolGroups {
         if (features.circuitBreaker()) {
             groups.add(ToolGroup.RESILIENCE);
         }
+        if (features.httpServed()) {
+            groups.add(ToolGroup.HTTP);
+        }
         return groups;
     }
 
     /**
-     * Sorted group ids and datasource names: the same integration gives the 
same fingerprint, whatever order its status
-     * lists things in.
+     * Sorted group ids and datasource names, and the HTTP base URL when the 
integration serves HTTP: the same
+     * integration gives the same fingerprint, whatever order its status lists 
things in.
      */
     static String fingerprint(List<ToolGroup> groups, AppFeatures features) {
         String ids = 
groups.stream().map(ToolGroup::id).sorted().collect(Collectors.joining(","));
         String ds = 
features.dataSourceNames().stream().sorted().collect(Collectors.joining(","));
-        return ids + "|" + ds;
+        String fp = ids + "|" + ds;
+        if (features.httpServed()) {
+            String url = features.http().baseUrl();
+            fp += "|" + (url != null ? url : "");
+        }
+        return fp;
+    }
+
+    /**
+     * Where the integration serves HTTP and its contract, e.g. {@code served 
on http://localhost:8080/api (contract
+     * stock-api.json)}.
+     */
+    public static String describeHttp(AppFeatures features) {
+        HttpEndpoints.Served http = features.http();
+        if (http == null) {
+            return "nothing served";
+        }
+        String where = http.baseUrl() != null
+                ? "served on " + http.baseUrl()
+                : "served, the port is not known yet";
+        return http.contract() != null ? where + " (contract " + 
http.contract() + ")" : where;
     }
 
     /**
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
index ad36c29da355..63c722cbb60d 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
@@ -442,21 +442,59 @@ public final class ToolRegistry {
                 }));
 
         register(tool("get_tool_groups",
-                "Which runtime tool groups (sql, tracing, resilience) the 
integration needs, from what it has: "
+                "Which runtime tool groups (sql, tracing, resilience, http) 
the integration needs, from what it has: "
                                          + "datasources and SQL endpoints, 
OpenTelemetry, message tracing, Micrometer, "
-                                         + "circuit breakers. Returns the 
tools of each group with one line of guidance, "
+                                         + "circuit breakers, HTTP endpoints. 
Returns the tools of each group with one line of guidance, "
                                          + "and a fingerprint that changes 
only when the groups do.")
                 .param("name", "string", "Name or PID of the integration 
(default: the selected or only one)", false)
                 .executor((ctx, args) -> {
-                    String name = args.get("name");
-                    if (name != null && !name.isBlank()) {
-                        ctx.selectProcess(name);
-                    } else {
-                        ctx.selectSingleProcessIfNone();
-                    }
+                    selectNamedProcess(ctx, args);
                     return toolGroups(ctx, ctx.readFullStatus()).toJson();
                 }));
 
+        // CAMEL-25307: what the integration serves over HTTP, and a request 
to it
+        register(tool("get_http_endpoints",
+                "List the HTTP endpoints the integration serves (Rest DSL and 
platform-http): method, path, "
+                                            + "consumes/produces, route and 
OpenAPI operation, the server's base URL "
+                                            + "and the contract. 
includeSpec=true adds the OpenAPI contract itself.")
+                .param("name", "string", "Name or PID of the integration 
(default: the selected or only one)", false)
+                .param("includeSpec", "boolean", "Add the OpenAPI contract of 
a contract-first service", false)
+                .executor((ctx, args) -> {
+                    selectNamedProcess(ctx, args);
+                    JsonObject answer = 
HttpEndpoints.toJson(HttpEndpoints.fromStatus(ctx.readFullStatus()));
+                    if ("true".equalsIgnoreCase(args.get("includeSpec"))) {
+                        answer.put("spec", 
HttpEndpoints.capSpecs(ctx.executeAction("rest-spec", null)));
+                    }
+                    return answer.toJson();
+                }));
+
+        register(tool("http_request",
+                "Send an HTTP request to the integration's own server 
(localhost and its port) and return the "
+                                      + "status, headers and body. Pass the 
path, e.g. /api/orders/1; other hosts are "
+                                      + "refused.")
+                .param("method", "string", "GET (default), POST, PUT, PATCH, 
DELETE, HEAD or OPTIONS", false)
+                .param("path", "string", "The path with query, e.g. 
/api/orders?status=open", true)
+                .param("headers", "string", "Headers as a JSON object or one 
'name: value' per line", false)
+                .param("body", "string", "The request body", false)
+                .param("timeoutSeconds", "integer", "How long to wait for the 
answer (default 30)", false)
+                .param("name", "string", "Name or PID of the integration 
(default: the selected or only one)", false)
+                .readOnly(false).destructive(false)
+                .executor((ctx, args) -> {
+                    selectNamedProcess(ctx, args);
+                    HttpEndpoints.Served served = 
HttpEndpoints.fromStatus(ctx.readFullStatus());
+                    int timeout = 30;
+                    try {
+                        String t = args.get("timeoutSeconds");
+                        if (t != null && !t.isBlank()) {
+                            timeout = (int) Double.parseDouble(t);
+                        }
+                    } catch (NumberFormatException e) {
+                        // use default
+                    }
+                    return HttpEndpoints.request(served != null ? 
served.port() : 0, args.get("method"),
+                            args.get("path"), args.get("headers"), 
args.get("body"), timeout).toJson();
+                }));
+
         // Route control
         registerRouteControlTool("stop_route", "stop",
                 "Gracefully stop a route. The route will finish processing 
in-flight exchanges before stopping.");
@@ -471,6 +509,16 @@ public final class ToolRegistry {
                 .executor((ctx, args) -> ctx.stopApplication()));
     }
 
+    /** Selects the process the call names, else keeps the selected one, else 
the only one running. */
+    private static void selectNamedProcess(ToolContext ctx, Map<String, 
String> args) {
+        String name = args.get("name");
+        if (name != null && !name.isBlank()) {
+            ctx.selectProcess(name);
+        } else {
+            ctx.selectSingleProcessIfNone();
+        }
+    }
+
     /**
      * The answer of get_tool_groups: the integration, the core tools every 
client has, the groups its status calls for
      * and the status keys that called for them.
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AppFeaturesTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AppFeaturesTest.java
index c49f9293998c..949f5464748d 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AppFeaturesTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AppFeaturesTest.java
@@ -129,4 +129,52 @@ class AppFeaturesTest {
         assertEquals(List.of("orders"), merged.dataSourceNames());
         assertEquals(merged, merged.merge(AppFeatures.none()));
     }
+
+    @Test
+    void platformHttpIsHttp() throws Exception {
+        // CAMEL-25307
+        AppFeatures f = features("{'platform-http': {'server': 
'http://0.0.0.0:8080', 'endpoints': ["
+                                 + "{'url': 'http://0.0.0.0:8080/api/orders', 
'path': '/api/orders', 'verbs': 'GET,POST'},"
+                                 + "{'url': 
'http://0.0.0.0:8080/api/orders/{id}', 'path': '/api/orders/{id}'}]}}");
+        assertTrue(f.httpServed());
+        assertEquals(8080, f.http().port());
+        assertEquals("http://localhost:8080/api";, f.http().baseUrl());
+        assertEquals(3, f.http().endpoints().size(), "one per verb, ANY 
without verbs");
+        assertEquals("http://0.0.0.0:8080";, f.signals().get("platform-http"));
+    }
+
+    @Test
+    void aContractFirstRestIsHttpWithItsContract() throws Exception {
+        AppFeatures f = features("{'rests': {'rests': [{'url': 
'http://0.0.0.0:8080/api/stock/{sku}', 'method': 'get',"
+                                 + " 'contractFirst': true, 'operationId': 
'getStock', 'routeId': 'stock',"
+                                 + " 'specificationUri': 
'classpath:stock-api.json'}]},"
+                                 + " 'platform-http': {'server': 
'http://0.0.0.0:9090'}}");
+        assertTrue(f.httpServed());
+        assertEquals("stock-api.json", f.http().contract());
+        assertEquals(9090, f.http().port(), "the embedded server knows the 
port");
+        assertEquals("/api/stock", f.http().basePath());
+        HttpEndpoints.Endpoint op = f.http().endpoints().get(0);
+        assertEquals("GET", op.method());
+        assertEquals("getStock", op.operationId());
+        assertEquals("1 service(s)", f.signals().get("rests"));
+        assertEquals("stock-api.json", f.signals().get("contract"));
+    }
+
+    @Test
+    void anHttpConsumerEndpointIsHttp() throws Exception {
+        // a status without the rest and platform-http consoles
+        AppFeatures f = features("{'routes': [{'routeId': 'r1', 'from': 
'rest-openapi://classpath:pets.yaml?x=y'}]}");
+        assertTrue(f.httpServed());
+        assertEquals("pets.yaml", f.http().contract());
+        assertEquals(0, f.http().port());
+        assertEquals("rest-openapi", f.signals().get("endpoints.http"));
+    }
+
+    @Test
+    void emptyHttpSectionsAreNotHttp() throws Exception {
+        AppFeatures f = features("{'rests': {'rests': []}, 'platform-http': 
{},"
+                                 + " 'endpoints': {'endpoints': [{'uri': 
'timer://tick'}, {'uri': 'http://example.com'}]}}");
+        assertFalse(f.httpServed());
+        assertTrue(ToolGroups.groups(f).isEmpty());
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/HttpEndpointsTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/HttpEndpointsTest.java
new file mode 100644
index 000000000000..e146d18cb498
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/HttpEndpointsTest.java
@@ -0,0 +1,153 @@
+/*
+ * 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.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import com.sun.net.httpserver.HttpServer;
+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.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class HttpEndpointsTest {
+
+    @Test
+    void theRequestGoesToTheIntegrationsOwnServer() {
+        assertEquals("http://localhost:8080/api/orders?status=open";,
+                HttpEndpoints.requestUri(8080, 
"/api/orders?status=open").toString());
+        assertEquals("http://localhost:8080/hello";, 
HttpEndpoints.requestUri(8080, "hello").toString());
+        // an absolute URL to the same server is fine
+        assertEquals("http://localhost:8080/api/x?a=1";,
+                HttpEndpoints.requestUri(8080, 
"http://0.0.0.0:8080/api/x?a=1";).toString());
+        assertEquals("http://localhost:8080/api/x";, 
HttpEndpoints.requestUri(8080, "http://127.0.0.1:8080/api/x";)
+                .toString());
+    }
+
+    @Test
+    void otherHostsAreRefused() {
+        for (String url : List.of("http://example.com/api";, 
"https://localhost:8080/api";, "http://localhost:9090/api";,
+                "//evil.example.com/x", "http://localhost.example.com:8080/x";, 
"ftp://localhost:8080/x";)) {
+            ToolExecutionException e
+                    = assertThrows(ToolExecutionException.class, () -> 
HttpEndpoints.requestUri(8080, url), url);
+            assertTrue(e.getMessage().startsWith("Only the integration's own 
server"), e.getMessage());
+        }
+        assertTrue(assertThrows(ToolExecutionException.class, () -> 
HttpEndpoints.requestUri(0, "/x"))
+                .getMessage().contains("port is not known"));
+        assertTrue(assertThrows(ToolExecutionException.class, () -> 
HttpEndpoints.requestUri(8080, " "))
+                .getMessage().contains("path is required"));
+    }
+
+    @Test
+    void headersAreJsonOrLines() {
+        assertEquals(Map.of("Accept", "application/json", "X-Id", "7"),
+                HttpEndpoints.parseHeaders("{\"Accept\": \"application/json\", 
\"X-Id\": 7}"));
+        assertEquals(Map.of("Accept", "text/plain", "X-Id", "a:b"),
+                HttpEndpoints.parseHeaders("Accept: text/plain\nX-Id=a:b"));
+        assertTrue(HttpEndpoints.parseHeaders(null).isEmpty());
+    }
+
+    @Test
+    void theBasePathIsWhatTheOperationsShare() {
+        assertEquals("/api", HttpEndpoints.basePath(List.of(op("/api/stock"), 
op("/api/stock/{sku}"), op("/api/x"))));
+        assertEquals("/api/stock", 
HttpEndpoints.basePath(List.of(op("/api/stock/{sku}"))));
+        assertEquals("", HttpEndpoints.basePath(List.of(op("/hello"))));
+        assertEquals("", HttpEndpoints.basePath(List.of(op("/a/x"), 
op("/b/y"))));
+        assertEquals("stock-api.json", 
HttpEndpoints.fileName("classpath:api/stock-api.json?x=y"));
+        assertNull(HttpEndpoints.fileName(null));
+    }
+
+    @Test
+    void theEndpointsAnswerListsServerContractAndOperations() throws Exception 
{
+        String json = "{'rests': {'rests': [{'url': 
'http://0.0.0.0:8080/api/hello', 'method': 'get',"
+                      + " 'routeId': 'hello', 'produces': 'text/plain'}]},"
+                      + " 'platform-http': {'server': 'http://0.0.0.0:8080', 
'endpoints': ["
+                      + "{'path': '/api/hello', 'verbs': 'GET'}, {'path': 
'/api/upload', 'verbs': 'POST'}]}}";
+        JsonObject status = (JsonObject) Jsoner.deserialize(json.replace('\'', 
'"'));
+        JsonObject answer = 
HttpEndpoints.toJson(HttpEndpoints.fromStatus(status));
+        assertEquals("http://localhost:8080/api";, ((JsonObject) 
answer.get("server")).get("baseUrl"));
+        JsonArray endpoints = (JsonArray) answer.get("endpoints");
+        assertEquals(2, endpoints.size(), "the Rest DSL operation is not 
listed twice");
+        assertEquals("hello", ((JsonObject) endpoints.get(0)).get("routeId"));
+        assertEquals("POST", ((JsonObject) endpoints.get(1)).get("method"));
+
+        
assertTrue(HttpEndpoints.toJson(null).get("hint").toString().contains("serves 
nothing over HTTP"));
+    }
+
+    @Test
+    void aLongContractIsCut() {
+        String spec = "{\"specs\": [{\"specificationUri\": \"a.json\", 
\"content\": \"" + "x".repeat(20000) + "\"}]}";
+        JsonObject capped = (JsonObject) HttpEndpoints.capSpecs(spec);
+        JsonObject first = (JsonObject) ((JsonArray) 
capped.get("specs")).get(0);
+        assertEquals(HttpEndpoints.MAX_SPEC_CHARS, 
first.get("content").toString().length());
+        assertTrue(first.get("truncated").toString().contains("20000"));
+        assertEquals("not json", HttpEndpoints.capSpecs("not json"));
+    }
+
+    @Test
+    void aRequestReturnsStatusHeadersAndACutBody() throws Exception {
+        AtomicReference<String> seen = new AtomicReference<>();
+        HttpServer server = HttpServer.create(new 
InetSocketAddress("localhost", 0), 0);
+        server.createContext("/", exchange -> {
+            String sent = new String(exchange.getRequestBody().readAllBytes(), 
StandardCharsets.UTF_8);
+            seen.set(exchange.getRequestMethod() + " " + 
exchange.getRequestURI() + " "
+                     + exchange.getRequestHeaders().getFirst("X-Id") + " " + 
sent);
+            byte[] body = (exchange.getRequestURI().getPath().endsWith("/big") 
? "y".repeat(10000) : "created")
+                    .getBytes(StandardCharsets.UTF_8);
+            exchange.getResponseHeaders().add("Content-Type", "text/plain");
+            
exchange.sendResponseHeaders(exchange.getRequestURI().getPath().endsWith("/moved")
 ? 302 : 201,
+                    body.length);
+            try (OutputStream out = exchange.getResponseBody()) {
+                out.write(body);
+            }
+        });
+        server.start();
+        try {
+            int port = server.getAddress().getPort();
+            JsonObject answer = HttpEndpoints.request(port, "post", 
"/api/orders?x=1", "X-Id: 7", "{\"a\":1}", 5);
+            assertEquals(201, answer.get("status"));
+            assertEquals("created", answer.get("body"));
+            assertEquals("POST /api/orders?x=1 7 {\"a\":1}", seen.get());
+            assertTrue(((JsonObject) 
answer.get("headers")).get("content-type").toString().contains("text/plain"));
+            assertFalse(answer.containsKey("truncated"));
+
+            answer = HttpEndpoints.request(port, null, "/big", null, null, 5);
+            assertEquals(HttpEndpoints.MAX_BODY_CHARS, 
answer.get("body").toString().length());
+            assertTrue(answer.get("truncated").toString().contains("10000"));
+
+            // a redirect is reported, not followed
+            assertEquals(302, HttpEndpoints.request(port, "GET", "/moved", 
null, null, 5).get("status"));
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    private static HttpEndpoints.Endpoint op(String path) {
+        return new HttpEndpoints.Endpoint("GET", path, null, null, null, null, 
"rest", false);
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroupsTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroupsTest.java
index c6400367830b..8570682716f6 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroupsTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolGroupsTest.java
@@ -111,4 +111,34 @@ class ToolGroupsTest {
         assertEquals(fp, ToolGroups.select(reordered).fingerprint());
         assertNotEquals(fp, 
ToolGroups.select(AppFeatures.none()).fingerprint());
     }
+
+    static HttpEndpoints.Served stockApi(int port) {
+        return new HttpEndpoints.Served(
+                port, "/api", "stock-api.json", List.of(new 
HttpEndpoints.Endpoint(
+                        "GET", "/api/stock/{sku}", null, "application/json", 
"stock", "getStock", "rest", false)),
+                Map.of("rests", "1 service(s)"));
+    }
+
+    @Test
+    void httpLoadsTheEndpointAndRequestTools() {
+        // CAMEL-25307
+        ToolGroups.Selection s = 
ToolGroups.select(AppFeatures.none().withHttp(stockApi(8080)));
+        assertEquals(List.of(ToolGroup.HTTP), s.toolGroups());
+        assertEquals(List.of("camel_runtime_http_endpoints", 
"camel_runtime_http_request"), s.mcpTools());
+        assertEquals("HTTP: served on http://localhost:8080/api (contract 
stock-api.json); camel_runtime_http_request"
+                     + " calls it, camel_runtime_http_endpoints lists the 
operations.",
+                s.guidance().get(0));
+        assertEquals("http||http://localhost:8080/api";, s.fingerprint());
+        assertEquals(s.fingerprint(), 
ToolGroups.select(AppFeatures.none().withHttp(stockApi(8080))).fingerprint());
+        assertNotEquals(s.fingerprint(), 
ToolGroups.select(AppFeatures.none().withHttp(stockApi(9090))).fingerprint());
+        
assertTrue(ToolGroups.select(AppFeatures.none().withHttp(stockApi(0))).guidance().get(0)
+                .contains("the port is not known yet"));
+    }
+
+    @Test
+    void httpComesLastSoTheOtherGroupsKeepTheirPlace() {
+        ToolGroups.Selection s = 
ToolGroups.select(everything().withHttp(stockApi(8080)));
+        assertEquals(List.of(ToolGroup.SQL, ToolGroup.TRACING, 
ToolGroup.RESILIENCE, ToolGroup.HTTP), s.toolGroups());
+        
assertEquals("http,resilience,sql,tracing|audit,orders|http://localhost:8080/api";,
 s.fingerprint());
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistryTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistryTest.java
index 36100dbb7f73..9416631e4236 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistryTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistryTest.java
@@ -180,6 +180,22 @@ class ToolRegistryTest {
         assertFalse(ToolRegistry.authoringTools().contains(tool));
     }
 
+    @Test
+    void theHttpToolsAreInternal() {
+        // CAMEL-25307: reached through camel_runtime_http_endpoints and 
camel_runtime_http_request
+        ToolDescriptor endpoints = ToolRegistry.findTool("get_http_endpoints");
+        ToolDescriptor request = ToolRegistry.findTool("http_request");
+        assertNotNull(endpoints);
+        assertNotNull(request);
+        assertTrue(endpoints.isReadOnly());
+        assertFalse(request.isReadOnly());
+        assertFalse(request.isDestructive());
+        assertFalse(ToolRegistry.authoringTools().contains(endpoints));
+        assertFalse(ToolRegistry.authoringTools().contains(request));
+        assertThrows(ToolExecutionException.class,
+                () -> ToolRegistry.execute("http_request", new ToolContext(), 
Map.of("path", "/hello")));
+    }
+
     @Test
     void toolGroupsAnswerFromTheStatus() throws Exception {
         ToolContext ctx = new ToolContext();
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeTools.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeTools.java
index f8fbe7bdbd48..f379b2165651 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeTools.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeTools.java
@@ -297,6 +297,48 @@ public class RuntimeTools {
         return delegateToRegistry("get_tool_groups", nameOrPid, Map.of());
     }
 
+    @Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint 
= false, openWorldHint = false),
+          description = """
+                  List the HTTP endpoints the running Camel application serves 
(Rest DSL and platform-http): \
+                  method, path, consumes/produces, route and OpenAPI 
operation, the server's base URL and the \
+                  contract. includeSpec=true adds the OpenAPI contract 
itself.""")
+    public JsonObject camel_runtime_http_endpoints(
+            @ToolArg(description = NAME_OR_PID_DESC, required = false) String 
nameOrPid,
+            @ToolArg(description = "Add the OpenAPI contract of a 
contract-first service (default false)",
+                     required = false) Boolean includeSpec) {
+        Map<String, String> args = new HashMap<>();
+        if (includeSpec != null && includeSpec) {
+            args.put("includeSpec", "true");
+        }
+        return delegateToRegistry("get_http_endpoints", nameOrPid, args);
+    }
+
+    @Tool(annotations = @Tool.Annotations(readOnlyHint = false, 
destructiveHint = false, openWorldHint = false),
+          description = """
+                  Send an HTTP request to the running Camel application's own 
server (localhost and its port) and \
+                  return the status, headers and body (cut at 6000 
characters). Pass the path, e.g. /api/orders/1; \
+                  other hosts are refused. Use camel_runtime_http_endpoints 
for the paths it serves.""")
+    public JsonObject camel_runtime_http_request(
+            @ToolArg(description = NAME_OR_PID_DESC, required = false) String 
nameOrPid,
+            @ToolArg(description = "GET (default), POST, PUT, PATCH, DELETE, 
HEAD or OPTIONS",
+                     required = false) String method,
+            @ToolArg(description = "The path with query, e.g. 
/api/orders?status=open") String path,
+            @ToolArg(description = "Headers as a JSON object or one 'name: 
value' per line",
+                     required = false) String headers,
+            @ToolArg(description = "The request body", required = false) 
String body) {
+        if (path == null || path.isBlank()) {
+            throw new ToolCallException("path is required, e.g. 
/api/orders/1", null);
+        }
+        Map<String, String> args = new HashMap<>();
+        args.put("path", path);
+        putIfNotBlank(args, "method", method);
+        putIfNotBlank(args, "headers", headers);
+        if (body != null) {
+            args.put("body", body);
+        }
+        return delegateToRegistry("http_request", nameOrPid, args);
+    }
+
     @Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint 
= false, openWorldHint = false),
           description = """
                   Get the datasources of the running Camel application with 
their connection pool status: \
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsTest.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsTest.java
index ebd305f6313f..f95a01a2d991 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeToolsTest.java
@@ -86,10 +86,43 @@ class RuntimeToolsTest {
                 tool.annotations().readOnlyHint(), 
tool.annotations().destructiveHint())).isTrue();
     }
 
+    @Test
+    void httpRequestRequiresPath() {
+        RuntimeTools tools = createTools();
+        assertThatThrownBy(() -> tools.camel_runtime_http_request(null, "GET", 
" ", null, null))
+                .isInstanceOf(ToolCallException.class)
+                .hasMessageContaining("path is required");
+    }
+
+    @Test
+    void theHttpToolsNeedAProcess() {
+        // CAMEL-25307: both reach the integration, so without one they say so
+        RuntimeTools tools = createTools();
+        assertThatThrownBy(() -> 
tools.camel_runtime_http_endpoints("no-such-app-25307", null))
+                .isInstanceOf(ToolCallException.class);
+        assertThatThrownBy(() -> 
tools.camel_runtime_http_request("no-such-app-25307", "GET", "/hello", null, 
null))
+                .isInstanceOf(ToolCallException.class);
+    }
+
+    @Test
+    void theHttpToolsHaveTheirAccessLevels() throws Exception {
+        // listing is read-only; a request may change data (a POST), but never 
reaches another host
+        Tool endpoints = 
RuntimeTools.class.getDeclaredMethod("camel_runtime_http_endpoints", 
String.class,
+                Boolean.class).getAnnotation(Tool.class);
+        assertThat(McpSecurityConfig.AccessLevel.READ_ONLY.permits(
+                endpoints.annotations().readOnlyHint(), 
endpoints.annotations().destructiveHint())).isTrue();
+        Tool request = 
RuntimeTools.class.getDeclaredMethod("camel_runtime_http_request", 
String.class, String.class,
+                String.class, String.class, 
String.class).getAnnotation(Tool.class);
+        assertThat(request.annotations().readOnlyHint()).isFalse();
+        assertThat(request.annotations().destructiveHint()).isFalse();
+        assertThat(request.annotations().openWorldHint()).isFalse();
+    }
+
     @Test
     void theNewWrappersDelegateToRegistryTools() {
         // CAMEL-24867: every wrapper names a tool the shared registry has, so 
a typo cannot hide until runtime
-        for (String name : List.of("execute_sql", "get_tool_groups", 
"get_datasources", "get_sql_trace",
+        for (String name : List.of("execute_sql", "get_tool_groups", 
"get_http_endpoints", "http_request", "get_datasources",
+                "get_sql_trace",
                 "get_circuit_breakers", "get_metrics",
                 "get_eip_stats", "get_spans", "get_startup_steps", 
"get_route_analysis", "detect_config_drift")) {
             assertThat(ToolRegistry.findTool(name)).as(name).isNotNull();
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
index 9cc851b316c4..e2be28681706 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolDefinitions.java
@@ -333,6 +333,21 @@ final class TuiToolDefinitions {
                         "body", propDef("string", "Message body to send"),
                         "headers", propDef("string", "Message headers as 
key=value pairs separated by newlines")),
                 List.of("endpoint"))));
+        // CAMEL-25307: the http tool group; the shared get_http_endpoints and 
http_request for the selected integration
+        tools.add(toToolDef(toolDef(
+                "tui_http_endpoints",
+                "HTTP endpoints the selected integration serves (Rest DSL, 
platform-http): method, path, "
+                                      + "consumes/produces, route, OpenAPI 
operation, base URL and contract.",
+                Map.of("includeSpec", propDef("boolean", "Add the OpenAPI 
contract of a contract-first service")))));
+        tools.add(toToolDef(toolDef(
+                "tui_http_request",
+                "Sends an HTTP request to the selected integration's own 
server (localhost and its port); returns "
+                                    + "status, headers and body. Other hosts 
are refused.",
+                Map.of("method", propDef("string", "GET (default), POST, PUT, 
PATCH, DELETE"),
+                        "path", propDef("string", "Path with query, e.g. 
/api/orders?status=open"),
+                        "headers", propDef("string", "JSON object or one 
'name: value' per line"),
+                        "body", propDef("string", "Request body")),
+                List.of("path"))));
         tools.add(toToolDef(toolDef(
                 "tui_execute_sql",
                 "Executes a SQL query against a DataSource in the selected 
integration. "
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolGroups.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolGroups.java
index 448841972cff..8233a4f74c8a 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolGroups.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolGroups.java
@@ -26,14 +26,16 @@ import 
org.apache.camel.dsl.jbang.core.commands.ai.ToolGroups;
 
 /**
  * The AI panel's side of {@link ToolGroups} (CAMEL-24834): the tui_* tools 
each group adds to the core set, and the
- * guidance line in the panel's words. Only SQL needs tools of its own; the 
spans, the traced messages, the metrics and
- * the circuit breakers are read with core tools (tui_get_spans, 
tui_get_history, tui_get_table), so those groups only
- * add the line that tells a small model where to look.
+ * guidance line in the panel's words. Only SQL and HTTP need tools of their 
own; the spans, the traced messages, the
+ * metrics and the circuit breakers are read with core tools (tui_get_spans, 
tui_get_history, tui_get_table), so those
+ * groups only add the line that tells a small model where to look.
  */
 final class TuiToolGroups {
 
     static final String SQL_TOOL = "tui_execute_sql";
     static final String UPDATE_ROW_TOOL = "tui_update_row";
+    static final String HTTP_ENDPOINTS_TOOL = "tui_http_endpoints";
+    static final String HTTP_REQUEST_TOOL = "tui_http_request";
 
     /**
      * The groups of an integration as the panel uses them.
@@ -75,10 +77,11 @@ final class TuiToolGroups {
     }
 
     static List<String> tools(ToolGroup group) {
-        if (group == ToolGroup.SQL) {
-            return List.of(SQL_TOOL, UPDATE_ROW_TOOL);
-        }
-        return List.of();
+        return switch (group) {
+            case SQL -> List.of(SQL_TOOL, UPDATE_ROW_TOOL);
+            case HTTP -> List.of(HTTP_ENDPOINTS_TOOL, HTTP_REQUEST_TOOL);
+            default -> List.of();
+        };
     }
 
     static String guidance(ToolGroup group, AppFeatures f) {
@@ -101,6 +104,8 @@ final class TuiToolGroups {
             case RESILIENCE -> ToolGroups.describeBreakers(f)
                                + ": tui_get_table tab 'Circuit Breaker' shows 
state (CLOSED/OPEN/HALF_OPEN) and failure"
                                + " rate; OPEN means the fallback runs.";
+            case HTTP -> "HTTP: " + ToolGroups.describeHttp(f) + "; " + 
HTTP_REQUEST_TOOL + " calls it, "
+                         + HTTP_ENDPOINTS_TOOL + " lists the operations.";
         };
     }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
index 89b2af9b9e4a..b46d2843a098 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
@@ -144,7 +144,10 @@ class TuiToolRegistry {
     private static Map<String, String> stringArgs(Map<String, Object> args) {
         Map<String, String> stringArgs = new LinkedHashMap<>();
         for (Map.Entry<String, Object> e : args.entrySet()) {
-            if (e.getValue() != null) {
+            if (e.getValue() instanceof Map<?, ?> || e.getValue() instanceof 
List<?>) {
+                // e.g. headers passed as an object: the shared tools read JSON
+                stringArgs.put(e.getKey(), Jsoner.serialize(e.getValue()));
+            } else if (e.getValue() != null) {
                 stringArgs.put(e.getKey(), String.valueOf(e.getValue()));
             }
         }
@@ -230,7 +233,7 @@ class TuiToolRegistry {
     private static final Set<String> READ_ONLY_TUI_TOOLS = Set.of(
             "tui_get_ai_log", "tui_get_diagram", "tui_get_events", 
"tui_get_history", "tui_get_mcp_log",
             "tui_get_ollama", "tui_get_options", "tui_get_processor_detail", 
"tui_get_readme", "tui_get_screen",
-            "tui_get_spans",
+            "tui_get_spans", "tui_http_endpoints",
             "tui_get_state", "tui_get_status", "tui_get_table", 
"tui_get_themes", "tui_get_topology",
             "tui_list_examples", "tui_locate", "tui_wait_for_idle");
 
@@ -306,6 +309,8 @@ class TuiToolRegistry {
             case "tui_get_topology" -> callGetTopology();
             case "tui_send_message" -> callSendMessage(args);
             case "tui_execute_sql" -> callExecuteSql(args);
+            case "tui_http_endpoints" -> executeShared("get_http_endpoints", 
args);
+            case "tui_http_request" -> executeShared("http_request", args);
             case "tui_update_row" -> callUpdateRow(args);
             case "tui_set_log_level" -> callSetLogLevel(args);
             case "tui_filter" -> callFilter(args);
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelPromptBudgetTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelPromptBudgetTest.java
index fca8515e5783..1237a0b52474 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelPromptBudgetTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelPromptBudgetTest.java
@@ -58,12 +58,17 @@ class AiPanelPromptBudgetTest {
     // saves far more tokens per edit than its schema costs once
     // raised from 9450 for camel_project_overview and 
camel_save_project_summary (CAMEL-25143), measured ~9750:
     // full mode only (hosted models), and the panel's own /overview sends no 
tools at all
-    static final int FULL_BUDGET_TOKENS = 9_850;
-    /** Measured ~5.2k tokens for 26 tools: the core set (~4.7k) plus every 
tool group (CAMEL-24834). */
+    // raised from 9850 for tui_http_endpoints and tui_http_request 
(CAMEL-25307), measured ~10070: a model that can
+    // see what the integration serves and call it is worth the ~250 tokens
+    static final int FULL_BUDGET_TOKENS = 10_250;
+    /** Measured ~5.5k tokens for 28 tools: the core set (~4.7k) plus every 
tool group (CAMEL-24834). */
     // the SQL group adds tui_execute_sql and tui_update_row (~385 tokens), 
each group one guidance line in the prompt
-    // (~130 for all three); an integration rarely has all three, and the 
groups only load for the integration that
+    // (~130 for all three); an integration rarely has all of them, and the 
groups only load for the integration that
     // needs them
-    static final int CORE_WITH_GROUPS_BUDGET_TOKENS = 5_500;
+    // raised from 5500 for the http group (CAMEL-25307), measured ~5460: 
tui_http_endpoints and tui_http_request
+    // (~245 tokens) and its guidance line (~35), for the local model that 
otherwise cannot see or call the HTTP API
+    // it is building
+    static final int CORE_WITH_GROUPS_BUDGET_TOKENS = 5_750;
 
     record Prefix(String mode, int tools, long promptChars, long toolChars) {
 
@@ -116,7 +121,10 @@ class AiPanelPromptBudgetTest {
         return new Prefix(mode, defs.size(), 
panel.systemPromptForTesting().length(), wireChars(defs));
     }
 
-    /** A core panel with every tool group loaded: datasources, OpenTelemetry, 
tracing, Micrometer, circuit breakers. */
+    /**
+     * A core panel with every tool group loaded: datasources, OpenTelemetry, 
tracing, Micrometer, circuit breakers,
+     * HTTP.
+     */
     static AiPanel coreWithAllGroups() {
         AiPanelToolGroupsTest.FakeApp app = new 
AiPanelToolGroupsTest.FakeApp();
         app.features = AiPanelToolGroupsTest.EVERYTHING;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelToolGroupsTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelToolGroupsTest.java
index 53fb1bf07691..d71d35624f72 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelToolGroupsTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelToolGroupsTest.java
@@ -21,6 +21,7 @@ import java.util.Map;
 
 import org.apache.camel.dsl.jbang.core.commands.LlmClient;
 import org.apache.camel.dsl.jbang.core.commands.ai.AppFeatures;
+import org.apache.camel.dsl.jbang.core.commands.ai.HttpEndpoints;
 import org.apache.camel.dsl.jbang.core.commands.ai.ToolGroup;
 import org.junit.jupiter.api.Test;
 
@@ -41,8 +42,13 @@ class AiPanelToolGroupsTest {
             List.of(), List.of(), false, true, List.of("pay"), false, false, 
false, Map.of());
     static final AppFeatures OTEL = new AppFeatures(
             List.of(), List.of(), false, false, List.of(), true, false, false, 
Map.of());
+    static final HttpEndpoints.Served STOCK_API = new HttpEndpoints.Served(
+            8080, "/api", "stock-api.json", List.of(new HttpEndpoints.Endpoint(
+                    "GET", "/api/stock/{sku}", null, "application/json", 
"stock", "getStock", "rest", false)),
+            Map.of("rests", "1 service(s)"));
+    static final AppFeatures HTTP = AppFeatures.none().withHttp(STOCK_API);
     static final AppFeatures EVERYTHING = SQL.merge(BREAKERS).merge(new 
AppFeatures(
-            List.of(), List.of(), false, false, List.of(), true, true, true, 
Map.of()));
+            List.of(), List.of(), false, false, List.of(), true, true, true, 
Map.of())).merge(HTTP);
 
     /** A selected integration whose pid, reload count and features a test 
changes, counting the status reads. */
     static final class FakeApp implements AiPanel.AppStatusSource {
@@ -95,6 +101,24 @@ class AiPanelToolGroupsTest {
                 panel.describeToolModeForTesting());
     }
 
+    @Test
+    void theHttpGroupAddsTheHttpTools() {
+        // CAMEL-25307: the endpoints and a request to them, only for an 
integration that serves HTTP
+        AiPanel plain = panel(AiPanel.TOOL_MODE_CORE, new FakeApp());
+        plain.refreshToolGroupsForTesting();
+        assertFalse(toolNames(plain).contains("tui_http_request"), "not a core 
tool");
+
+        FakeApp app = new FakeApp();
+        app.features = HTTP;
+        AiPanel panel = panel(AiPanel.TOOL_MODE_CORE, app);
+        panel.refreshToolGroupsForTesting();
+        assertEquals(List.of(ToolGroup.HTTP), 
panel.toolGroupsForTesting().groups());
+        assertTrue(toolNames(panel).containsAll(List.of("tui_http_endpoints", 
"tui_http_request")));
+        assertTrue(panel.systemPromptForTesting().contains(
+                "- HTTP: served on http://localhost:8080/api (contract 
stock-api.json); tui_http_request calls it,"
+                                                           + " 
tui_http_endpoints lists the operations.\n"));
+    }
+
     @Test
     void anotherIntegrationGetsItsOwnGroups() {
         FakeApp app = new FakeApp();

Reply via email to