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 61e9f6c35505 CAMEL-26398: camel-jbang - AI tools: the file tools take
an absolute file inside the directory and the client's root, and name a file
found in a subdirectory (#28681)
61e9f6c35505 is described below
commit 61e9f6c3550560c27c17e4cc0f7239f4225acb5a
Author: Federico Mariani <[email protected]>
AuthorDate: Thu Oct 29 13:39:47 2026 +0100
CAMEL-26398: camel-jbang - AI tools: the file tools take an absolute file
inside the directory and the client's root, and name a file found in a
subdirectory (#28681)
camel_validate_source, camel_get_files, camel_edit_file and camel_write_file
refused calls whose intended file was clear: an absolute file inside the
directory, a call without directory from a client that declares its project
as
its only MCP root, a file name given relative to the wrong directory, the
YAML
passed as the file, and a write into a directory that does not exist yet.
The
answers did not say what to pass instead.
One resolver (AuthoringTools.fileTarget) now serves the four tools. The
directory is the directory argument, else the default directory (TUI, camel
ask), else the client's only file: root, asked for only when a call needs
it,
once per call, with a short timeout, and only of a client that declares
roots.
Without any of them the call is refused with what to pass. The file is a
path
relative to the directory or an absolute path inside it. As reviewed on the
JIRA, the file must always stay inside the directory: both are resolved with
toRealPath() (the nearest existing parent for a path not there yet), so a
path that leaves the directory, an absolute path outside it, or a symbolic
link in the project that leads out of it is refused, and an absolute file
never names the directory. camel_write_file creates a missing directory only
inside the root (or the default directory); otherwise the answer names the
existing directory to pass with the subdirectory in file. A file not found
is
named when a file of that name is in a subdirectory, and a file of several
lines is answered with the argument the source goes in.
The upgrade guide notes that a file reached through a symbolic link out of
the project
directory is now refused.
---
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 9 +
.../modules/ROOT/pages/camel-jbang-mcp.adoc | 21 +-
.../ROOT/pages/camel-jbang-tui-ai-agents.adoc | 4 +-
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 269 +++++++++++++--
.../dsl/jbang/core/commands/ai/ToolContext.java | 43 ++-
.../commands/ai/AuthoringToolsFilePathsTest.java | 361 +++++++++++++++++++++
.../jbang/core/commands/ai/AuthoringToolsTest.java | 7 +
.../jbang/core/commands/mcp/AuthoringTools.java | 98 +++++-
.../src/main/resources/application.properties | 4 +
.../core/commands/mcp/AuthoringToolsRootsTest.java | 189 +++++++++++
.../core/commands/mcp/AuthoringToolsTest.java | 28 +-
.../commands/mcp/McpSecurityInterceptorTest.java | 8 +-
.../core/commands/tui/McpFacadeWriteFileTest.java | 4 +-
.../jbang/core/commands/tui/TuiFileToolsTest.java | 35 ++
14 files changed, 1020 insertions(+), 60 deletions(-)
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 719673edee95..50e86e06e8f3 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -459,6 +459,15 @@ that a route sends to and no route consumes, so they
report the same endpoints o
So `camel validate` can report an endpoint it did not report before.
+=== camel-jbang - the AI file tools stay inside the project directory, also
through a symbolic link
+
+The file tools of `camel mcp`, `camel ask` and the TUI AI agents
(`camel_validate_source`, `camel_get_files`,
+`camel_edit_file`, `camel_write_file`) now resolve symbolic links before they
check that a file is inside the project
+directory. A file reached through a link in the project that leads out of it,
or through a link to nothing, is
+refused for reading and for writing, where it used to be read or written. Name
the files of such a folder through
+its real directory instead. An absolute `file` inside the project directory is
now accepted, as the same file named
+relative to it.
+
=== camel-ftp, camel-mina-sftp, camel-jsch, camel-smb, camel-azure-files -
checksumFileAlgorithm writes a checksum again
Since 4.18 the producers of the remote file components (ftp, ftps, sftp,
mina-sftp, scp, smb, azure-files) accepted
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 5e8d87da7b0d..e007e01e0a3f 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -294,6 +294,24 @@ under the same `camel_` names by this server and by `camel
tui --mcp` (see xref:
so an agent gets the same Camel through either door. The tools are
self-contained: the file tools take the
project `directory` as an argument, the runtime tools take the integration
`name` (or use the only one running).
+The file tools (`camel_validate_source`, `camel_get_files`, `camel_edit_file`
and `camel_write_file`) work in the
+project `directory` the call passes. A call without `directory` works in the
client's root when the client
+declares exactly one `file:` root (the MCP roots: asked for only by such a
call and only of a client that declares
+them, waiting at most 2 seconds); otherwise it is refused, and the answer says
to pass `directory`, the absolute
+project directory.
+
+The `file` is a path relative to that directory, or an absolute path inside
it, which is taken relative to it. The
+file always stays inside the directory, with symbolic links resolved: a path
with `..` that leaves it, an absolute
+path outside it, or a link in the project that leads out of it is refused. An
absolute `file` never stands for the
+directory, so a call cannot read or write another file of the disk through it.
+
+`camel_write_file` creates the subdirectories of `file`, and a missing
`directory` only when it is inside the
+client's root. Without a root, the answer names the existing directory to
pass, with the subdirectory in `file`:
+`{"directory":"/workspace","file":"camel/application.properties"}`. A file
that is not found is named when the
+directory has a file of that name in a subdirectory, as in
+`camel/order-intake.yaml exists: pass file camel/order-intake.yaml`, and a
`file` of several lines, the source
+passed as the file name, is answered with the argument the source goes in.
+
[cols="1,3",options="header"]
|===
| Tool | Description
@@ -393,7 +411,8 @@ project `directory` as an argument, the runtime tools take
the integration `name
first; invalid content is not written and the errors are returned. A YAML
shape that a model often gets wrong but
that has only one reading, such as `steps:` at the column of its EIP or an
option at the column of the EIP it
belongs to, is repaired instead: the file is written with the repairs, and
`repaired` in the answer lists them.
- Only a plain file name in the directory is accepted. Nobody is asked before
the write: the MCP client (Claude Code,
+ The file stays inside the directory, and the subdirectories of the file are
created (see above). Nobody is
+ asked before the write: the MCP client (Claude Code,
Cursor and the others ask before a tool that is not read-only runs) is where
the human sits, and the
`read-only` access level of the security layer hides the tool altogether.
With the security layer on, content longer than
`camel.mcp.security.max-argument-length` is rejected, never cut,
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
index 6ac23c3a6293..6f6c9c1e2e4f 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
@@ -203,7 +203,9 @@ project, where the routes sit under
`src/main/resources/camel` rather than next
listing understands that layout: it names the route and configuration files
first, lists every file
with its path relative to the project (build output skipped), and maps each
running route to its file
and line, so the agent reads the right file in one call instead of guessing
names. File paths given to
-`camel_get_files` and `camel_write_file` are relative to that directory and
may name a subdirectory.
+`camel_get_files` and `camel_write_file` are relative to that directory and
may name a subdirectory; an
+absolute path inside that directory is taken too, and a path that leaves it,
also through a symbolic link, is
+refused.
Every write is confirmed in the TUI first: a dialog names the file, the
directory and the size of the
change (`+3 -1` lines); press *d* to see the change as a unified diff, with
removed lines on red and
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
index ba7b23274be5..62e249e17336 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
@@ -22,6 +22,7 @@ import java.nio.file.FileVisitOption;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
+import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
@@ -63,10 +64,10 @@ import static
org.apache.camel.dsl.jbang.core.commands.ai.ToolDescriptor.tool;
* an expression and diagnosing an error.
* <p>
* The tools are self-contained: a file tool takes the project {@code
directory} as an argument (a server that has a
- * selected integration fills it in through {@link
ToolContext#setDefaultDirectory}), and a runtime tool takes the
- * integration {@code name} (or uses the selected or the only running one).
Writing a file is validated first and
- * refused when the content is invalid; confirming a write with a human is the
job of the client or of the TUI, not of
- * these tools.
+ * selected integration fills it in through {@link
ToolContext#setDefaultDirectory}, see {@link #fileTarget}), and a
+ * runtime tool takes the integration {@code name} (or uses the selected or
the only running one). Writing a file is
+ * validated first and refused when the content is invalid; confirming a write
with a human is the job of the client or
+ * of the TUI, not of these tools.
*/
public final class AuthoringTools {
@@ -193,17 +194,18 @@ public final class AuthoringTools {
String file = required(args, "file");
String content = args.get("content");
String directory = args.get("directory");
- // the directory is needed to read the file, and used for
the bean reference check when given
- Path dir = content == null || (directory != null &&
!directory.isBlank())
- ? ctx.resolveDirectory(directory) : null;
if (content == null) {
- Path path = resolveFile(dir, file);
- if (!Files.isRegularFile(path)) {
- throw new ToolExecutionException("No such file in
the directory: " + file);
+ FileTarget target = fileTarget(ctx, directory, file,
FileAccess.VALIDATE);
+ if (!Files.isRegularFile(target.path())) {
+ throw new ToolExecutionException(
+ "No such file in the directory " +
target.directory() + ": " + target.file()
+ +
elsewhere(target, "; "));
}
- content = read(path);
- return validate(ctx, dir, file, content).toJson();
+ return validate(ctx, target.directory(),
target.file(), read(target.path())).toJson();
}
+ checkFileName(file, FileAccess.VALIDATE);
+ // the content is checked on its own, and with the other
files of the directory when one is given
+ Path dir = directory != null && !directory.isBlank() ?
ctx.resolveDirectory(directory) : null;
JsonObject result = validate(ctx, dir, file, content);
if (sameAsOnDisk(ctx, directory, file, content)) {
// a model that meant to change the file validates the
old version: say so (CAMEL-25371)
@@ -223,11 +225,12 @@ public final class AuthoringTools {
.param("file", "string", FILE_PATH_DESC + " to read; omitted
lists the files", false)
.core(true)
.executor((ctx, args) -> {
- Path dir = ctx.resolveDirectory(args.get("directory"));
String file = args.get("file");
if (file != null && !file.isBlank()) {
- return readFile(dir, file).toJson();
+ FileTarget target = fileTarget(ctx,
args.get("directory"), file, FileAccess.READ);
+ return readFile(target.directory(),
target.file()).toJson();
}
+ Path dir = ctx.resolveDirectory(args.get("directory"));
JsonObject result = listFiles(dir);
if (ctx.hasProcess()) {
// the selected integration's routes, when they come
from this directory
@@ -254,9 +257,9 @@ public final class AuthoringTools {
.core(true)
.executor((ctx, args) -> {
applyVersion(ctx, args);
- Path dir = ctx.resolveDirectory(args.get("directory"));
+ FileTarget target = fileTarget(ctx, args.get("directory"),
required(args, "file"), FileAccess.WRITE);
// always validated: a model given a switch turns it off
(CAMEL-24897)
- return writeFile(ctx, dir, required(args, "file"),
required(args, "content"), true).toJson();
+ return writeFile(ctx, target.directory(), target.file(),
required(args, "content"), true).toJson();
}));
registry.accept(tool("camel_edit_file",
@@ -277,8 +280,8 @@ public final class AuthoringTools {
.core(true)
.executor((ctx, args) -> {
applyVersion(ctx, args);
- Path dir = ctx.resolveDirectory(args.get("directory"));
- return editFile(ctx, dir, required(args, "file"),
args.get("find"),
+ FileTarget target = fileTarget(ctx, args.get("directory"),
required(args, "file"), FileAccess.EDIT);
+ return editFile(ctx, target.directory(), target.file(),
args.get("find"),
args.get("replace") == null ? "" :
args.get("replace"), args.get("edits")).toJson();
}));
@@ -760,7 +763,12 @@ public final class AuthoringTools {
static JsonObject editedContent(Path dir, String file, String find, String
replace, String edits, boolean closest) {
Path path = resolveFile(dir, file);
if (!Files.isRegularFile(path)) {
- throw new ToolExecutionException(file + " does not exist: write
the whole file with camel_write_file");
+ String elsewhere = elsewhere(dir, path, "");
+ // a file of that name in a subdirectory is the one to change: a
whole new file would be a second copy
+ throw new ToolExecutionException(
+ file + " does not exist" + (!elsewhere.isEmpty()
+ ? ", but " + elsewhere + " (camel_write_file
writes a new file)"
+ : ": write the whole file with camel_write_file"));
}
List<String[]> all = new ArrayList<>();
if (find != null && !find.isEmpty()) {
@@ -2006,9 +2014,13 @@ public final class AuthoringTools {
public static JsonObject readFile(Path dir, String file) {
Path path = resolveFile(dir, file);
if (!Files.isRegularFile(path)) {
+ String elsewhere = elsewhere(dir, path, "; ");
throw new ToolExecutionException(
"No such file: " + file + " in " + dir
- + "; call camel_get_files without
file to list them (routeFiles names the routes)");
+ + (!elsewhere.isEmpty()
+ ? elsewhere
+ : "; call camel_get_files
without file to list them (routeFiles"
+ + " names the
routes)"));
}
JsonObject result = new JsonObject();
result.put("file", file);
@@ -2211,28 +2223,225 @@ public final class AuthoringTools {
}
/**
- * A file path relative to the directory (subdirectories allowed, as
{@code camel_get_files} lists them); an
- * absolute path or one that escapes the directory is refused.
+ * What a file tool does with its file, which decides whether a missing
directory is created, and which argument the
+ * source goes in.
*/
- public static Path resolveFile(Path dir, String file) {
+ public enum FileAccess {
+ /** Reads the file ({@code camel_get_files}). */
+ READ(null),
+ /** Reads the file, or validates the content given for it ({@code
camel_validate_source}). */
+ VALIDATE("content"),
+ /** Changes the file ({@code camel_edit_file}). */
+ EDIT(null),
+ /** Writes the file ({@code camel_write_file}): the only one to create
a missing directory. */
+ WRITE("content");
+
+ private final String sourceArgument;
+
+ FileAccess(String sourceArgument) {
+ this.sourceArgument = sourceArgument;
+ }
+ }
+
+ /**
+ * The file of a file tool call: the project directory it is in, the path
relative to it as the answers name it, and
+ * the path itself.
+ */
+ public record FileTarget(Path directory, String file, Path path) {
+ }
+
+ /**
+ * Where the file of a file tool call is, the same way for {@code
camel_validate_source}, {@code camel_get_files},
+ * {@code camel_edit_file} and {@code camel_write_file} (CAMEL-26398):
+ * <ul>
+ * <li>the directory is the {@code directory} argument, else the default
directory (the TUI's selection,
+ * {@code camel ask}'s project), else the root directory (the only root of
an MCP client); without any of them the
+ * answer says what to pass;</li>
+ * <li>the file is a path relative to the directory, or an absolute path
inside it, which is made relative;</li>
+ * <li>only {@code camel_write_file} creates a missing {@code directory},
and only inside the default or root
+ * directory; elsewhere the answer names the existing directory to pass,
with the subdirectory in the file.</li>
+ * </ul>
+ * The file always stays inside the directory, with symbolic links
resolved: an absolute path, or a link in the
+ * project, is never a way to read or write another file of the disk, and
an absolute file never names the
+ * directory.
+ *
+ * @throws ToolExecutionException when the call does not say where the
file is, or the file is not inside the
+ * directory
+ */
+ public static FileTarget fileTarget(ToolContext ctx, String directory,
String file, FileAccess access) {
+ Path given = checkFileName(file, access);
+ Path dir;
+ if (directory != null && !directory.isBlank()) {
+ try {
+ dir = Path.of(directory).toAbsolutePath().normalize();
+ } catch (InvalidPathException e) {
+ throw new ToolExecutionException("Not a valid directory: " +
directory);
+ }
+ if (!Files.isDirectory(dir)) {
+ missingDirectory(ctx, dir, file, access);
+ }
+ } else {
+ Path project = ctx.projectDirectory();
+ if (project == null) {
+ throw new ToolExecutionException(
+ "directory is required: pass directory, the absolute
project directory with the source files,"
+ + " and file relative to it"
+ + (given.isAbsolute() ? " (an
absolute file must be inside it)" : ""));
+ }
+ dir = project.toAbsolutePath().normalize();
+ if (!Files.isDirectory(dir)) {
+ throw new ToolExecutionException("No such directory: " + dir);
+ }
+ }
+ Path path = resolveFile(dir, file);
+ return new FileTarget(dir, relativePath(dir, path), path);
+ }
+
+ /**
+ * The file name of a call, refused when it is missing, not a path, or the
source itself: a model that put the YAML
+ * of a route in {@code file} was answered with the whole YAML as a file
it could not find.
+ */
+ static Path checkFileName(String file, FileAccess access) {
if (file == null || file.isBlank()) {
throw new ToolExecutionException("file is required");
}
- Path path;
+ if (file.indexOf('\n') >= 0) {
+ throw new ToolExecutionException(
+ "file has several lines, so it is not a file name: file
names the file, e.g. routes.camel.yaml"
+ + (access.sourceArgument != null
+ ? ", and the source goes
in " + access.sourceArgument : ""));
+ }
try {
- if (Path.of(file).isAbsolute()) {
- throw new ToolExecutionException("file must be a path relative
to the directory " + dir + ", not " + file);
+ return Path.of(file);
+ } catch (InvalidPathException e) {
+ throw new ToolExecutionException("Not a valid file path: " + file);
+ }
+ }
+
+ /**
+ * Checks a directory the call names that is not there: a write creates it
when it is inside the default or root
+ * directory, an existing project directory. Otherwise it is refused, and
a write is told the existing directory to
+ * pass, with the subdirectory in the file, whose directories the write
creates.
+ */
+ private static void missingDirectory(ToolContext ctx, Path dir, String
file, FileAccess access) {
+ if (Files.exists(dir, LinkOption.NOFOLLOW_LINKS)) {
+ throw new ToolExecutionException(dir + " is not a directory");
+ }
+ if (access == FileAccess.WRITE) {
+ Path project = ctx.projectDirectory();
+ Path realProject = project != null && Files.isDirectory(project)
+ ? realPath(project.toAbsolutePath().normalize()) : null;
+ Path realDir = realPath(dir);
+ if (realProject != null && realDir != null &&
realDir.startsWith(realProject)) {
+ return;
}
- path = dir.resolve(file).normalize();
+ // the nearest directory that is there, not a link that may lead
out of the project
+ Path existing = dir.getParent();
+ while (existing != null && !Files.isDirectory(existing,
LinkOption.NOFOLLOW_LINKS)) {
+ existing = existing.getParent();
+ }
+ Path target = existing != null ? dir.resolve(file).normalize() :
null;
+ if (target != null && target.startsWith(existing) &&
!target.equals(existing)) {
+ // the arguments of the call to make, as the model writes them
(no escaped slashes)
+ throw new ToolExecutionException(
+ "No such directory: " + dir + "; pass the existing
project directory as directory and the"
+ + " subdirectory in file,
which is created: {\"directory\":\""
+ +
existing.toString().replace('\\', '/') + "\",\"file\":\""
+ + relativePath(existing,
target) + "\"}");
+ }
+ }
+ throw new ToolExecutionException("No such directory: " + dir);
+ }
+
+ /** As {@link #elsewhere(Path, Path, String)} for the file of a call. */
+ private static String elsewhere(FileTarget target, String prefix) {
+ return elsewhere(target.directory(), target.path(), prefix);
+ }
+
+ /**
+ * Where a file that is not at the path is under the directory: the files
of that name in its subdirectories, as
+ * {@code camel/order-intake.yaml exists: pass file
camel/order-intake.yaml} after the prefix, or an empty string
+ * when there is none. A model that knew the name passed it relative to
the wrong directory.
+ */
+ static String elsewhere(Path dir, Path path, String prefix) {
+ if (path == null || path.getFileName() == null ||
!Files.isDirectory(dir)) {
+ return "";
+ }
+ String name = path.getFileName().toString();
+ List<String> found;
+ try {
+ // the walk of the listing: bounded, without build output, VCS and
hidden directories, and links not followed
+ found = projectFiles(dir).stream().filter(p ->
p.getFileName().toString().equals(name) && !p.equals(path))
+ .map(p -> relativePath(dir, p)).limit(3).toList();
+ } catch (ToolExecutionException e) {
+ return "";
+ }
+ if (found.isEmpty()) {
+ return "";
+ }
+ return prefix + (found.size() == 1
+ ? found.get(0) + " exists: pass file " + found.get(0)
+ : String.join(", ", found) + " exist: pass file as the one you
mean");
+ }
+
+ /**
+ * A file path relative to the directory (subdirectories allowed, as
{@code camel_get_files} lists them), or an
+ * absolute path inside the directory. The file must stay inside the
directory with symbolic links resolved: a path
+ * that leaves it, or that a link in it leads out of, is refused
(CAMEL-26398).
+ */
+ public static Path resolveFile(Path dir, String file) {
+ if (file == null || file.isBlank()) {
+ throw new ToolExecutionException("file is required");
+ }
+ Path base = dir.toAbsolutePath().normalize();
+ Path given;
+ try {
+ given = Path.of(file);
} catch (InvalidPathException e) {
throw new ToolExecutionException("Not a valid file path: " + file);
}
- if (!path.startsWith(dir) || path.equals(dir)) {
- throw new ToolExecutionException("file must stay inside the
directory " + dir + ": " + file);
+ Path path = base.resolve(given).normalize();
+ boolean lexicallyInside = path.startsWith(base) && !path.equals(base);
+ if (!given.isAbsolute() && !lexicallyInside) {
+ throw new ToolExecutionException("file must stay inside the
directory " + base + ": " + file);
+ }
+ Path realBase = realPath(base);
+ Path realFile = realPath(path);
+ if (realBase == null || realFile == null ||
!realFile.startsWith(realBase) || realFile.equals(realBase)) {
+ throw new ToolExecutionException(
+ given.isAbsolute()
+ ? "file " + file + " is not inside the directory "
+ base
+ + ": pass file relative to the directory, or the
absolute path of a file inside it"
+ : "file must stay inside the directory " + base +
": " + file
+ + " (a symbolic link leads out of it)");
+ }
+ if (!lexicallyInside) {
+ // inside only through a link of the path: on macOS /tmp is
/private/tmp, and a client may name either
+ path = base.resolve(realBase.relativize(realFile));
}
return path;
}
+ /**
+ * The path with its symbolic links resolved; for a path that is not there
yet (a file or directory a write
+ * creates), the real path of its nearest existing parent with the rest of
the path. Null when it cannot be
+ * resolved, such as a link to nothing, which a write would follow out of
the directory.
+ */
+ static Path realPath(Path path) {
+ Path existing = path;
+ while (existing != null && !Files.exists(existing,
LinkOption.NOFOLLOW_LINKS)) {
+ existing = existing.getParent();
+ }
+ if (existing == null) {
+ return null;
+ }
+ try {
+ return
existing.toRealPath().resolve(existing.relativize(path)).normalize();
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
private static String read(Path path) {
try {
return Files.readString(path, StandardCharsets.UTF_8);
@@ -2606,7 +2815,7 @@ public final class AuthoringTools {
/** Whether the content is exactly the file's content on disk; false when
there is no such file. */
static boolean sameAsOnDisk(ToolContext ctx, String directory, String
file, String content) {
try {
- Path path = resolveFile(ctx.resolveDirectory(directory), file);
+ Path path = fileTarget(ctx, directory, file,
FileAccess.VALIDATE).path();
return Files.isRegularFile(path) &&
read(path).strip().equals(content.strip());
} catch (Exception e) {
return false;
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolContext.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolContext.java
index d057db674dfb..8f5dd93f5155 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolContext.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolContext.java
@@ -24,6 +24,7 @@ import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
+import java.util.function.Supplier;
import org.apache.camel.catalog.CamelCatalog;
import org.apache.camel.catalog.DefaultCamelCatalog;
@@ -47,6 +48,10 @@ public class ToolContext {
private volatile CamelCatalog catalog;
private volatile String camelVersion;
private volatile Path defaultDirectory;
+ // guarded by this: the root is asked for once per context
+ private Supplier<Path> rootDirectory;
+ private Path rootLookedUp;
+ private boolean rootKnown;
private volatile Function<String, String> propertyLineValidator;
private volatile boolean autoSelectSingleProcess = true;
private volatile Predicate<ToolConfirms.Call> confirmer;
@@ -122,7 +127,7 @@ public class ToolContext {
/**
* The project directory the file tools use when a call names none: the
TUI sets it to the source directory of the
- * selected integration, an MCP server without a selection leaves it null
and requires the argument.
+ * selected integration, {@code camel ask} to the project directory; an
MCP server leaves it null.
*/
public Path defaultDirectory() {
return defaultDirectory;
@@ -132,6 +137,26 @@ public class ToolContext {
this.defaultDirectory = defaultDirectory;
}
+ /**
+ * The project directory the client declares, such as the only {@code
file:} root of an MCP client: the file tools
+ * read and write there when a call names no directory and there is no
default directory, and a write creates a
+ * directory the call names inside it. Asking the client is a round trip,
so the root is looked up when a call first
+ * needs it, and once per context; null when there is none.
+ */
+ public synchronized Path rootDirectory() {
+ if (rootDirectory != null && !rootKnown) {
+ rootLookedUp = rootDirectory.get();
+ rootKnown = true;
+ }
+ return rootLookedUp;
+ }
+
+ public synchronized void setRootDirectory(Supplier<Path> rootDirectory) {
+ this.rootDirectory = rootDirectory;
+ this.rootLookedUp = null;
+ this.rootKnown = false;
+ }
+
/**
* Who the shared tools ask before a call that the user confirms first
({@link ToolConfirms}), such as a file write:
* {@code camel ask} asks on the terminal (CAMEL-25676). Null, the
default, asks nobody: an MCP server leaves that
@@ -152,14 +177,16 @@ public class ToolContext {
}
/**
- * Resolves the directory of a file tool call: the {@code directory}
argument, else the default directory.
+ * Resolves the directory of a file tool call: the {@code directory}
argument, else the default directory, else the
+ * root directory.
*
- * @throws ToolExecutionException when neither is given or the directory
does not exist
+ * @throws ToolExecutionException when none is given or the directory does
not exist
*/
public Path resolveDirectory(String directory) {
- Path dir = directory != null && !directory.isBlank() ?
Path.of(directory) : defaultDirectory;
+ Path dir = directory != null && !directory.isBlank() ?
Path.of(directory) : projectDirectory();
if (dir == null) {
- throw new ToolExecutionException("directory is required: the
project directory with the source files");
+ throw new ToolExecutionException(
+ "directory is required: pass directory, the absolute
project directory with the source files");
}
dir = dir.toAbsolutePath().normalize();
if (!Files.isDirectory(dir)) {
@@ -168,6 +195,12 @@ public class ToolContext {
return dir;
}
+ /** The directory a call that names none works in: the default directory,
else the root directory; may be null. */
+ Path projectDirectory() {
+ Path dir = defaultDirectory;
+ return dir != null ? dir : rootDirectory();
+ }
+
/**
* An extra check for a line of a .properties file that the catalog does
not know (the TUI validates Spring Boot
* properties from the project's metadata); returns the message, or null
when the line is fine.
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsFilePathsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsFilePathsTest.java
new file mode 100644
index 000000000000..e4ce83aa5d88
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsFilePathsTest.java
@@ -0,0 +1,361 @@
+/*
+ * 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.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-26398: the file tools (camel_validate_source, camel_get_files,
camel_edit_file, camel_write_file) take the file
+ * the way a model names it: an absolute file inside the directory, a relative
file in the client's root, a file in a
+ * subdirectory the answer names. The file always stays inside the project
directory, symbolic links resolved: an
+ * absolute path never reads or writes another file of the disk.
+ */
+class AuthoringToolsFilePathsTest {
+
+ private static final String ROUTE = """
+ - route:
+ id: orders
+ from:
+ uri: timer:tick
+ steps:
+ - log:
+ message: "${body}"
+ """;
+
+ @TempDir
+ Path workspace;
+
+ private Path routes;
+
+ @BeforeEach
+ void project() throws IOException {
+ // the layout of the eval: the routes in camel/ under the directory
the agent works in
+ routes =
Files.createDirectories(workspace.resolve("camel")).resolve("main-route.yaml");
+ Files.writeString(routes, ROUTE, StandardCharsets.UTF_8);
+ }
+
+ private static JsonObject call(ToolContext ctx, String tool, Map<String,
String> args) {
+ Object result = ToolRegistry.execute(tool, ctx, args);
+ return Jsoner.deserialize(String.valueOf(result), new JsonObject());
+ }
+
+ private static String error(ToolContext ctx, String tool, Map<String,
String> args) {
+ try {
+ ToolRegistry.execute(tool, ctx, args);
+ } catch (ToolExecutionException e) {
+ return e.getMessage();
+ }
+ throw new AssertionError(tool + " " + args + " did not fail");
+ }
+
+ @Test
+ void anAbsoluteFileInsideTheDirectoryIsMadeRelativeToIt() throws
IOException {
+ Map<String, String> args = Map.of("directory", workspace.toString(),
"file", routes.toString());
+ JsonObject validated = call(new ToolContext(),
"camel_validate_source", args);
+
assertThat(validated.getBoolean("valid")).as(validated.toJson()).isTrue();
+
assertThat(validated.getString("file")).isEqualTo("camel/main-route.yaml");
+
+ assertThat(call(new ToolContext(), "camel_get_files",
args).getString("file")).isEqualTo("camel/main-route.yaml");
+
+ JsonObject edited = call(new ToolContext(), "camel_edit_file",
Map.of("directory", workspace.toString(), "file",
+ routes.toString(), "find", "id: orders", "replace", "id:
order-intake"));
+
assertThat(edited.getString("status")).as(edited.toJson()).isEqualTo("edited");
+ assertThat(Files.readString(routes)).contains("id: order-intake");
+
+ JsonObject written = call(new ToolContext(), "camel_write_file",
+ Map.of("directory", workspace.toString(), "file",
workspace.resolve("application.properties").toString(),
+ "content", "camel.main.name=orders\n"));
+
assertThat(written.getString("status")).as(written.toJson()).isEqualTo("created");
+
assertThat(written.getString("file")).isEqualTo("application.properties");
+
assertThat(Files.readString(workspace.resolve("application.properties"))).isEqualTo("camel.main.name=orders\n");
+ }
+
+ @Test
+ void anAbsoluteFileInsideTheDirectoryOnlyThroughASymbolicLinkIsInsideIt()
throws IOException {
+ // on macOS a temporary directory is under /var, a symbolic link to
/private/var, and a client may name either
+ Path real = workspace.toRealPath();
+ Map<String, String> args = Map.of("directory", real.toString(),
"file", routes.toString());
+
+ assertThat(call(new ToolContext(), "camel_get_files",
args).getString("file")).isEqualTo("camel/main-route.yaml");
+ }
+
+ @Test
+ void anAbsoluteFileOutsideTheDirectoryIsRefused() throws IOException {
+ Path project = Files.createDirectories(workspace.resolve("project"));
+ Map<String, String> args = Map.of("directory", project.toString(),
"file", routes.toString());
+
+ assertThat(error(new ToolContext(), "camel_get_files", args))
+ .isEqualTo("file " + routes + " is not inside the directory "
+ project
+ + ": pass file relative to the directory, or the
absolute path of a file inside it");
+ assertThat(error(new ToolContext(), "camel_validate_source",
args)).contains("is not inside the directory");
+ assertThat(error(new ToolContext(), "camel_write_file",
Map.of("directory", project.toString(), "file",
+ workspace.resolve("new.yaml").toString(), "content",
ROUTE))).contains("is not inside the directory");
+ assertThat(workspace.resolve("new.yaml")).doesNotExist();
+ }
+
+ @Test
+ void anAbsoluteFileNeverNamesTheDirectory() {
+ // the directory of the file would let a call read any file of the disk
+ for (String tool : new String[] { "camel_validate_source",
"camel_get_files" }) {
+ assertThat(error(new ToolContext(), tool, Map.of("file",
routes.toString())))
+ .isEqualTo("directory is required: pass directory, the
absolute project directory with the source"
+ + " files, and file relative to it (an absolute
file must be inside it)");
+ }
+ Path file = workspace.resolve("camel/application.properties");
+ assertThat(error(new ToolContext(), "camel_write_file", Map.of("file",
file.toString(), "content", "a=b\n")))
+ .startsWith("directory is required");
+ assertThat(file).doesNotExist();
+ }
+
+ @Test
+ void aCallThatNamesNoPlaceSaysWhatToPass() {
+ assertThat(error(new ToolContext(), "camel_validate_source",
Map.of("file", "camel/main-route.yaml")))
+ .isEqualTo("directory is required: pass directory, the
absolute project directory with the source"
+ + " files, and file relative to it");
+ assertThat(error(new ToolContext(), "camel_get_files",
Map.of())).startsWith("directory is required: pass"
+
+ " directory");
+ }
+
+ @Test
+ void aRelativeFileWithoutDirectoryIsInTheRoot() throws IOException {
+ ToolContext ctx = new ToolContext();
+ AtomicInteger asked = new AtomicInteger();
+ ctx.setRootDirectory(() -> {
+ asked.incrementAndGet();
+ return workspace;
+ });
+
+ JsonObject validated = call(ctx, "camel_validate_source",
Map.of("file", "camel/main-route.yaml"));
+
assertThat(validated.getBoolean("valid")).as(validated.toJson()).isTrue();
+ JsonObject written = call(ctx, "camel_write_file",
+ Map.of("file", "camel/application.properties", "content",
"camel.main.name=orders\n"));
+
assertThat(written.getString("status")).as(written.toJson()).isEqualTo("created");
+
assertThat(Files.readString(workspace.resolve("camel/application.properties")))
+ .isEqualTo("camel.main.name=orders\n");
+ // asking the client is a round trip: once per context
+ assertThat(asked).hasValue(1);
+ }
+
+ @Test
+ void anAbsoluteFileIsTakenInsideTheRootOnly() {
+ ToolContext ctx = new ToolContext();
+ ctx.setRootDirectory(() -> workspace.resolve("camel"));
+
+ assertThat(call(ctx, "camel_validate_source", Map.of("file",
routes.toString())).getString("file"))
+ .isEqualTo("main-route.yaml");
+ assertThat(error(ctx, "camel_get_files", Map.of("file",
workspace.resolve("other.yaml").toString())))
+ .contains("is not inside the directory " +
workspace.resolve("camel"));
+ }
+
+ @Test
+ void theRootIsNotAskedForWhenTheCallNamesTheDirectory() {
+ ToolContext ctx = new ToolContext();
+ AtomicInteger asked = new AtomicInteger();
+ ctx.setRootDirectory(() -> {
+ asked.incrementAndGet();
+ return workspace;
+ });
+
+ call(ctx, "camel_get_files", Map.of("directory", workspace.toString(),
"file", "camel/main-route.yaml"));
+
+ assertThat(asked).hasValue(0);
+ }
+
+ @Test
+ void theDefaultDirectoryComesBeforeTheRoot() throws IOException {
+ // the TUI's selected integration, or camel ask's project, is where a
relative file is
+ Path selected = Files.createDirectories(workspace.resolve("selected"));
+ Files.writeString(selected.resolve("main-route.yaml"), ROUTE);
+ ToolContext ctx = new ToolContext();
+ ctx.setDefaultDirectory(selected);
+ ctx.setRootDirectory(() -> {
+ throw new AssertionError("the root is not asked for");
+ });
+
+ JsonObject read = call(ctx, "camel_get_files", Map.of("file",
"main-route.yaml"));
+
+ assertThat(read.getString("directory")).isEqualTo(selected.toString());
+ assertThat(error(ctx, "camel_get_files", Map.of("file",
routes.toString())))
+ .contains("is not inside the directory " + selected);
+ }
+
+ @Test
+ void aWriteCreatesAMissingDirectoryInsideTheRoot() throws IOException {
+ ToolContext ctx = new ToolContext();
+ ctx.setRootDirectory(() -> workspace);
+ Path named = workspace.resolve("camel-new");
+
+ JsonObject written = call(ctx, "camel_write_file",
+ Map.of("directory", named.toString(), "file",
"application.properties", "content", "a=b\n"));
+
+
assertThat(written.getString("status")).as(written.toJson()).isEqualTo("created");
+
assertThat(Files.readString(named.resolve("application.properties"))).isEqualTo("a=b\n");
+ }
+
+ @Test
+ void aWriteDoesNotCreateADirectoryOutsideAProject() {
+ // no root: the existing directory to pass, with the subdirectory in
the file, which a write creates
+ Path named = workspace.resolve("camel-new");
+ String message = error(new ToolContext(), "camel_write_file",
+ Map.of("directory", named.toString(), "file",
"application.properties", "content", "a=b\n"));
+ assertThat(message).isEqualTo("No such directory: " + named + "; pass
the existing project directory as"
+ + " directory and the subdirectory in
file, which is created: {\"directory\":\""
+ + workspace +
"\",\"file\":\"camel-new/application.properties\"}");
+ assertThat(named).doesNotExist();
+
+ // a root elsewhere is not a project the directory is in
+ ToolContext ctx = new ToolContext();
+ ctx.setRootDirectory(() -> workspace.resolve("camel"));
+ assertThat(error(ctx, "camel_write_file",
+ Map.of("directory", named.toString(), "file",
"application.properties", "content", "a=b\n")))
+ .startsWith("No such directory: " + named);
+ assertThat(named).doesNotExist();
+ }
+
+ @Test
+ void anInvalidWriteCreatesNoDirectory() {
+ ToolContext ctx = new ToolContext();
+ ctx.setRootDirectory(() -> workspace);
+ Path named = workspace.resolve("camel-new");
+
+ JsonObject refused = call(ctx, "camel_write_file", Map.of("directory",
named.toString(), "file",
+ "routes.yaml", "content", ROUTE.replace("message:",
"mesage:")));
+
+
assertThat(refused.getString("status")).as(refused.toJson()).isEqualTo("invalid");
+ assertThat(named).doesNotExist();
+ }
+
+ @Test
+ void onlyTheWriteCreatesADirectoryAndNeverTheRoot() {
+ ToolContext ctx = new ToolContext();
+ ctx.setRootDirectory(() -> workspace);
+ Path missing = workspace.resolve("missing");
+ assertThat(error(ctx, "camel_get_files", Map.of("directory",
missing.toString(), "file", "a.yaml")))
+ .isEqualTo("No such directory: " + missing);
+ assertThat(error(ctx, "camel_edit_file",
+ Map.of("directory", missing.toString(), "file", "a.yaml",
"find", "a", "replace", "b")))
+ .isEqualTo("No such directory: " + missing);
+
+ ToolContext rooted = new ToolContext();
+ rooted.setRootDirectory(() -> missing);
+ assertThat(error(rooted, "camel_write_file", Map.of("file",
"a.properties", "content", "a=b\n")))
+ .isEqualTo("No such directory: " + missing);
+ assertThat(missing).doesNotExist();
+ }
+
+ @Test
+ void aFileNotFoundNamesTheFileOfThatNameInASubdirectory() throws
IOException {
+ // the copy under target/ is build output, not the one to name
+ Files.createDirectories(workspace.resolve("target/classes"));
+ Files.writeString(workspace.resolve("target/classes/main-route.yaml"),
ROUTE);
+ Map<String, String> args = Map.of("directory", workspace.toString(),
"file", "main-route.yaml");
+
+ assertThat(error(new ToolContext(), "camel_validate_source", args))
+ .isEqualTo("No such file in the directory " + workspace + ":
main-route.yaml; camel/main-route.yaml"
+ + " exists: pass file camel/main-route.yaml");
+ assertThat(error(new ToolContext(), "camel_get_files", args))
+ .endsWith("; camel/main-route.yaml exists: pass file
camel/main-route.yaml");
+ // an edit that would otherwise be told to write a new file, a second
copy of it
+ assertThat(error(new ToolContext(), "camel_edit_file",
+ Map.of("directory", workspace.toString(), "file",
"main-route.yaml", "find", "id: orders", "replace",
+ "id: x")))
+ .isEqualTo("main-route.yaml does not exist, but
camel/main-route.yaml exists: pass file"
+ + " camel/main-route.yaml (camel_write_file writes
a new file)");
+ }
+
+ @Test
+ void theSourcePassedAsTheFileIsToldToGoInContent() {
+ String message = error(new ToolContext(), "camel_validate_source",
+ Map.of("directory", workspace.toString(), "file", ROUTE));
+ assertThat(message).isEqualTo("file has several lines, so it is not a
file name: file names the file, e.g."
+ + " routes.camel.yaml, and the source
goes in content");
+
+ // the edit has no content: find and replace say what to change
+ assertThat(error(new ToolContext(), "camel_edit_file",
+ Map.of("directory", workspace.toString(), "file", ROUTE,
"find", "a", "replace", "b")))
+ .doesNotContain("content");
+ }
+
+ @Test
+ void aFileStillStaysInsideItsDirectory() throws IOException {
+ Files.writeString(workspace.resolve("outside.yaml"), ROUTE);
+ ToolContext rooted = new ToolContext();
+ rooted.setRootDirectory(() -> workspace.resolve("camel"));
+ assertThat(error(rooted, "camel_write_file", Map.of("file",
"../escaped.yaml", "content", ROUTE)))
+ .contains("file must stay inside the directory");
+ assertThat(error(new ToolContext(), "camel_get_files",
+ Map.of("directory", workspace.resolve("camel").toString(),
"file", "sub/../../outside.yaml")))
+ .contains("file must stay inside the directory");
+ assertThat(workspace.resolve("escaped.yaml")).doesNotExist();
+ }
+
+ @Test
+ @DisabledOnOs(OS.WINDOWS)
+ void aSymbolicLinkInTheProjectDoesNotLeadOutOfIt() throws IOException {
+ Path outside = Files.createDirectories(workspace.resolve("outside"));
+ Files.writeString(outside.resolve("secret.yaml"), ROUTE);
+ Path project = Files.createDirectories(workspace.resolve("project"));
+ Files.createSymbolicLink(project.resolve("shared"), outside);
+ Files.createSymbolicLink(project.resolve("linked.yaml"),
outside.resolve("secret.yaml"));
+ Files.createSymbolicLink(project.resolve("dangling.yaml"),
outside.resolve("created.yaml"));
+ ToolContext ctx = new ToolContext();
+ ctx.setRootDirectory(() -> project);
+
+ for (String file : new String[] { "shared/secret.yaml", "linked.yaml"
}) {
+ assertThat(error(ctx, "camel_get_files", Map.of("file", file)))
+ .isEqualTo("file must stay inside the directory " +
project + ": " + file
+ + " (a symbolic link leads out of it)");
+ assertThat(error(ctx, "camel_validate_source", Map.of("file",
file))).contains("a symbolic link leads out");
+ }
+ assertThat(error(ctx, "camel_get_files", Map.of("file",
project.resolve("shared/secret.yaml").toString())))
+ .contains("is not inside the directory " + project);
+ // a write through a link, also one to nothing yet, would create or
change the file outside
+ assertThat(error(ctx, "camel_write_file", Map.of("file",
"shared/new.yaml", "content", ROUTE)))
+ .contains("a symbolic link leads out");
+ assertThat(error(ctx, "camel_write_file", Map.of("file",
"dangling.yaml", "content", ROUTE)))
+ .contains("a symbolic link leads out");
+ assertThat(error(ctx, "camel_write_file",
+ Map.of("directory", project.resolve("shared/sub").toString(),
"file", "new.yaml", "content", ROUTE)))
+ .startsWith("No such directory");
+ assertThat(outside.resolve("new.yaml")).doesNotExist();
+ assertThat(outside.resolve("created.yaml")).doesNotExist();
+ assertThat(outside.resolve("sub")).doesNotExist();
+ }
+
+ @Test
+ void contentTheSameAsTheAbsoluteFileOnDiskIsNoted() {
+ JsonObject validated = call(new ToolContext(), "camel_validate_source",
+ Map.of("directory", workspace.toString(), "file",
routes.toString(), "content", ROUTE));
+
+
assertThat(validated.getBoolean("sameAsFile")).as(validated.toJson()).isTrue();
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
index 61099db11db6..27f651ce818d 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
@@ -438,6 +438,13 @@ class AuthoringToolsTest {
ToolExecutionException e = assertThrows(ToolExecutionException.class,
() -> ToolRegistry.execute("camel_get_files", new
ToolContext(),
Map.of("directory", dir.toString(), "file",
"camel/timer-log.camel.yaml")));
+ // the error names the file of that name the project has (CAMEL-26398)
+ assertTrue(e.getMessage().endsWith(";
src/main/resources/camel/timer-log.camel.yaml exists: pass file"
+ + "
src/main/resources/camel/timer-log.camel.yaml"),
+ "the error says how to find the file: " + e.getMessage());
+ e = assertThrows(ToolExecutionException.class,
+ () -> ToolRegistry.execute("camel_get_files", new
ToolContext(),
+ Map.of("directory", dir.toString(), "file",
"camel/no-such.camel.yaml")));
assertTrue(e.getMessage().contains("routeFiles"), "the error says how
to find the file: " + e.getMessage());
}
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
index 6a5b5bd3fa8e..56392718b63a 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
@@ -16,15 +16,23 @@
*/
package org.apache.camel.dsl.jbang.core.commands.mcp;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.file.FileSystemNotFoundException;
+import java.nio.file.Path;
+import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.function.Consumer;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import io.quarkiverse.mcp.server.McpConnection;
import io.quarkiverse.mcp.server.MetaField;
+import io.quarkiverse.mcp.server.Root;
+import io.quarkiverse.mcp.server.Roots;
import io.quarkiverse.mcp.server.Tool;
import io.quarkiverse.mcp.server.ToolArg;
import io.quarkiverse.mcp.server.ToolCallException;
@@ -58,6 +66,11 @@ public class AuthoringTools {
private static final String NAME_DESC = "Integration name or pid (default:
the only one running)";
private static final String VERSION_DESC = "Camel version to answer for
(default: the CLI's own)";
private static final String DIRECTORY_DESC = "Project directory with the
source files (absolute path)";
+ /**
+ * How long a file tool waits for the client's roots, at most: the
server's own timeout
+ * ({@code quarkus.mcp.server.roots.default-timeout}) is shorter, this is
the cap should it be configured off.
+ */
+ static final Duration ROOTS_WAIT = Duration.ofSeconds(3);
@Inject
RepeatedCallSessions repeatedCalls;
@@ -153,7 +166,7 @@ public class AuthoringTools {
+ "its builder() accepts, the ways to create a class
with no constructor). Use on content "
+ "before writing it, or on an existing file (no
content) to explain a reload error.")
public JsonObject camel_validate_source(
- McpConnection connection,
+ McpConnection connection, Roots roots,
@ToolArg(description = DIRECTORY_DESC + "; needed when no content
is given", required = false) String directory,
@ToolArg(description = "File name; picks the checks by extension,
read when no content",
required = true) String file,
@@ -162,7 +175,7 @@ public class AuthoringTools {
// the same content without a directory gets the same answer: the
third identical call gets a short note
// (CAMEL-25371)
return callDeterministic(connection, "camel_validate_source",
args("directory", directory, "file", file,
- "content", content, "camelVersion", camelVersion));
+ "content", content, "camelVersion", camelVersion),
clientRoot(roots));
}
@ToolGuardrails(input = McpArgumentGuardrail.class, output =
McpArgumentGuardrail.class)
@@ -172,11 +185,12 @@ public class AuthoringTools {
+ "running integration, which file and line each route
comes from; with file (a path "
+ "relative to the directory, as listed) its content.")
public JsonObject camel_get_files(
+ Roots roots,
@ToolArg(description = DIRECTORY_DESC, required = false) String
directory,
@ToolArg(description = "File path relative to the directory, e.g.
src/main/resources/camel/foo.camel.yaml,"
+ " to read; omitted lists the files",
required = false) String file) {
- return call("camel_get_files", args("directory", directory, "file",
file));
+ return call("camel_get_files", args("directory", directory, "file",
file), null, clientRoot(roots));
}
@ToolGuardrails(input = McpArgumentGuardrail.class, output =
McpArgumentGuardrail.class)
@@ -186,6 +200,7 @@ public class AuthoringTools {
+ "An integration running in dev mode reloads the
change, otherwise restart it with "
+ "camel_control.")
public JsonObject camel_write_file(
+ Roots roots,
@ToolArg(description = DIRECTORY_DESC, required = false) String
directory,
@ToolArg(description = "File path relative to the directory
(subdirectories are created)",
required = true) String file,
@@ -193,7 +208,7 @@ public class AuthoringTools {
@ToolArg(description = VERSION_DESC, required = false) String
camelVersion) {
refuseRedacted(content);
return call("camel_write_file", args("directory", directory, "file",
file, "content", content,
- "camelVersion", camelVersion));
+ "camelVersion", camelVersion), null, clientRoot(roots));
}
@ToolGuardrails(input = McpArgumentGuardrail.class, output =
McpArgumentGuardrail.class)
@@ -203,6 +218,7 @@ public class AuthoringTools {
+ "camel_write_file for a new one. A change to several
places of the file is given at once in "
+ "edits, so the file is written and reloaded once,
not half done.")
public JsonObject camel_edit_file(
+ Roots roots,
@ToolArg(description = DIRECTORY_DESC, required = false) String
directory,
@ToolArg(description = "File path relative to the directory",
required = true) String file,
@ToolArg(description = "The lines to replace as they stand in the
file; other indentation is fine when "
@@ -221,7 +237,59 @@ public class AuthoringTools {
}
}
return call("camel_edit_file", args("directory", directory, "file",
file, "find", find, "replace", replace,
- "edits", editsJson(edits), "camelVersion", camelVersion));
+ "edits", editsJson(edits), "camelVersion", camelVersion),
null, clientRoot(roots));
+ }
+
+ /**
+ * The project directory of a file tool call that names none
(CAMEL-26398): the client's only {@code file:} root,
+ * asked for only when a call needs it, and only of a client that declares
roots. The file stays inside it, as
+ * inside a directory the call names.
+ */
+ static Consumer<ToolContext> clientRoot(Roots roots) {
+ return ctx -> {
+ if (roots != null && roots.isSupported() &&
roots.isServerInitiatedRequestSupported()) {
+ ctx.setRootDirectory(() -> singleRoot(listRoots(roots)));
+ }
+ };
+ }
+
+ /** The client's roots; none when it does not answer in time or answers
with an error. */
+ static List<Root> listRoots(Roots roots) {
+ try {
+ List<Root> list = roots.list().await().atMost(ROOTS_WAIT);
+ return list != null ? list : List.of();
+ } catch (RuntimeException e) {
+ LOG.debugf("The client did not list its roots (%s): file tools
take no default directory", e.getMessage());
+ return List.of();
+ }
+ }
+
+ /** The directory of the only {@code file:} root, or null when there is
none or more than one. */
+ static Path singleRoot(List<Root> roots) {
+ Path only = null;
+ for (Root root : roots) {
+ Path dir = root != null ? fileRoot(root.uri()) : null;
+ if (dir == null) {
+ continue;
+ }
+ if (only != null) {
+ return null;
+ }
+ only = dir;
+ }
+ return only;
+ }
+
+ private static Path fileRoot(String uri) {
+ if (uri == null) {
+ return null;
+ }
+ try {
+ URI parsed = new URI(uri);
+ return "file".equalsIgnoreCase(parsed.getScheme()) ?
Path.of(parsed).toAbsolutePath().normalize() : null;
+ } catch (URISyntaxException | IllegalArgumentException |
FileSystemNotFoundException e) {
+ return null;
+ }
}
/**
@@ -403,11 +471,17 @@ public class AuthoringTools {
* against the repeat budget; a call that throws does not (CAMEL-25640).
*/
JsonObject callDeterministic(McpConnection connection, String tool,
Map<String, String> args) {
+ return callDeterministic(connection, tool, args, null);
+ }
+
+ /** As {@link #callDeterministic(McpConnection, String, Map)}, with what
the tool's context gets beside the args. */
+ JsonObject callDeterministic(
+ McpConnection connection, String tool, Map<String, String> args,
Consumer<ToolContext> configure) {
JsonObject repeat = repeatedCalls != null ?
repeatedCalls.repeatOf(connection, tool, args) : null;
if (repeat != null) {
return repeat;
}
- JsonObject result = call(tool, args, catalogService);
+ JsonObject result = call(tool, args, catalogService, configure);
if (repeatedCalls != null) {
repeatedCalls.recordSuccessfulAnswer(connection, tool, args);
}
@@ -425,8 +499,20 @@ public class AuthoringTools {
* new one per call (CAMEL-25640).
*/
static JsonObject call(String tool, Map<String, String> args,
CatalogService catalogService) {
+ return call(tool, args, catalogService, null);
+ }
+
+ /**
+ * As {@link #call(String, Map, CatalogService)}; {@code configure}, when
given, sets up the context of the call,
+ * such as where the file tools look for a file the call names without a
directory.
+ */
+ static JsonObject call(
+ String tool, Map<String, String> args, CatalogService
catalogService, Consumer<ToolContext> configure) {
try {
ToolContext ctx = new ToolContext();
+ if (configure != null) {
+ configure.accept(ctx);
+ }
if (catalogService != null) {
String camelVersion = args != null ? args.get("camelVersion")
: null;
try {
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/main/resources/application.properties
b/dsl/camel-jbang/camel-jbang-mcp/src/main/resources/application.properties
index f3ff503bb223..1b6ea1df345a 100644
--- a/dsl/camel-jbang/camel-jbang-mcp/src/main/resources/application.properties
+++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/resources/application.properties
@@ -33,6 +33,10 @@ quarkus.log.category."io.quarkiverse.mcp".level=INFO
# Disable HTTP server - use STDIO transport only for CLI integration
quarkus.http.host-enabled=false
+# The file tools ask the client for its roots when a call names no directory:
a client that does not answer
+# quickly gets an answer without a default directory, not a call that hangs.
+quarkus.mcp.server.roots.default-timeout=2s
+
# Strip null fields from JSON responses to reduce LLM token consumption.
# Applies to @Tool result records serialized via JsonTextContentEncoder.
quarkus.jackson.serialization-inclusion=non-null
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsRootsTest.java
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsRootsTest.java
new file mode 100644
index 000000000000..52b86dcb0437
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsRootsTest.java
@@ -0,0 +1,189 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.mcp;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
+
+import io.quarkiverse.mcp.server.InputResponses;
+import io.quarkiverse.mcp.server.Root;
+import io.quarkiverse.mcp.server.Roots;
+import io.quarkiverse.mcp.server.ToolCallException;
+import io.smallrye.mutiny.Uni;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * CAMEL-26398: a file tool call that names no directory finds the file in the
client's only root, and stays inside it.
+ * The roots are the server's {@link Roots} as the tool methods get it, with
the client's answer faked: a real round
+ * trip needs a client, which this module's tests do not start.
+ */
+class AuthoringToolsRootsTest {
+
+ private static final String ROUTE = "- route:\n from:\n uri:
timer:tick\n steps:\n - log:\n"
+ + " message: hi\n";
+
+ private final AuthoringTools tools = new AuthoringTools();
+
+ @TempDir
+ Path workspace;
+
+ @BeforeEach
+ void project() throws Exception {
+ Files.createDirectories(workspace.resolve("camel"));
+ Files.writeString(workspace.resolve("camel/main-route.yaml"), ROUTE,
StandardCharsets.UTF_8);
+ }
+
+ @Test
+ void theOnlyFileRootIsTheDirectory() {
+ Path dir = workspace.toAbsolutePath().normalize();
+ String uri = dir.toUri().toString();
+
+ assertThat(AuthoringTools.singleRoot(List.of(new Root("project",
uri)))).isEqualTo(dir);
+ assertThat(AuthoringTools.singleRoot(List.of(new Root("docs",
"https://example.com/docs"), new Root("p", uri))))
+ .as("only the file: roots count").isEqualTo(dir);
+ assertThat(AuthoringTools.singleRoot(List.of(new Root("a", uri), new
Root(
+ "b", dir.resolve("camel").toUri()
+ .toString()))))
+ .as("two roots: none is the default").isNull();
+ assertThat(AuthoringTools.singleRoot(List.of())).isNull();
+ assertThat(AuthoringTools.singleRoot(List.of(new Root("bad",
"file://host/share/x"), new Root("worse", "::"))))
+ .isNull();
+ }
+
+ @Test
+ void aRelativeFileIsReadAndWrittenInTheClientsRoot() throws Exception {
+ FakeRoots roots = FakeRoots.answering(List.of(new Root("project",
workspace.toUri().toString())));
+
+ JsonObject read = tools.camel_get_files(roots, null,
"camel/main-route.yaml");
+ assertThat(read.getString("content")).isEqualTo(ROUTE);
+ JsonObject validated = tools.camel_validate_source(null, roots, null,
"camel/main-route.yaml", null, null);
+
assertThat(validated.getBoolean("valid")).as(validated.toJson()).isTrue();
+ JsonObject written = tools.camel_write_file(roots, null,
"camel/application.properties", "a=b\n", null);
+
assertThat(written.getString("status")).as(written.toJson()).isEqualTo("created");
+
assertThat(Files.readString(workspace.resolve("camel/application.properties"))).isEqualTo("a=b\n");
+ JsonObject edited = tools.camel_edit_file(roots, null,
"camel/application.properties", "a=b", "a=c", null, null);
+
assertThat(edited.getString("status")).as(edited.toJson()).isEqualTo("edited");
+ // a directory the call names inside the root is created by the write
+ JsonObject created = tools.camel_write_file(roots,
workspace.resolve("config").toString(),
+ "application.properties", "a=b\n", null);
+
assertThat(created.getString("status")).as(created.toJson()).isEqualTo("created");
+ }
+
+ @Test
+ void theFileStaysInsideTheRoot() throws Exception {
+ FakeRoots roots = FakeRoots.answering(List.of(new Root("project",
workspace.resolve("camel").toUri().toString())));
+ Path outside = workspace.resolve("other.yaml");
+ Files.writeString(outside, ROUTE, StandardCharsets.UTF_8);
+
+ assertThatThrownBy(() -> tools.camel_get_files(roots, null,
outside.toString()))
+
.isInstanceOf(ToolCallException.class).hasMessageContaining("is not inside the
directory");
+ assertThatThrownBy(() -> tools.camel_get_files(roots, null,
"../other.yaml"))
+
.isInstanceOf(ToolCallException.class).hasMessageContaining("file must stay
inside the directory");
+ }
+
+ @Test
+ void theRootsAreAskedForOnlyWhenTheCallNamesNoDirectory() {
+ FakeRoots roots = FakeRoots.answering(List.of(new Root("project",
workspace.toUri().toString())));
+
+ tools.camel_get_files(roots, workspace.toString(),
"camel/main-route.yaml");
+ assertThat(roots.asked).hasValue(0);
+ tools.camel_get_files(roots, null,
workspace.resolve("camel/main-route.yaml").toString());
+ assertThat(roots.asked).as("an absolute file inside the
root").hasValue(1);
+ }
+
+ @Test
+ void aClientWithoutRootsIsNotAsked() {
+ FakeRoots roots = new FakeRoots(false, () -> {
+ throw new AssertionError("a client that did not declare roots is
not asked for them");
+ });
+
+ assertThatThrownBy(() -> tools.camel_get_files(roots, null,
"camel/main-route.yaml"))
+
.isInstanceOf(ToolCallException.class).hasMessageStartingWith("directory is
required");
+ assertThat(roots.asked).hasValue(0);
+ }
+
+ @Test
+ void aClientThatFailsToListItsRootsGivesNoDefault() {
+ FakeRoots failing = new FakeRoots(true, () ->
Uni.createFrom().failure(new IllegalStateException("no roots")));
+
+ assertThatThrownBy(() -> tools.camel_write_file(failing, null,
"application.properties", "a=b\n", null))
+
.isInstanceOf(ToolCallException.class).hasMessageStartingWith("directory is
required");
+ }
+
+ @Test
+ void aClientThatDoesNotAnswerGivesNoDefaultAfterTheWait() {
+ FakeRoots silent = new FakeRoots(true, () ->
Uni.createFrom().nothing());
+
+ long start = System.nanoTime();
+ assertThatThrownBy(() -> tools.camel_get_files(silent, null,
"camel/main-route.yaml"))
+
.isInstanceOf(ToolCallException.class).hasMessageStartingWith("directory is
required");
+ assertThat(System.nanoTime() -
start).isLessThan(AuthoringTools.ROOTS_WAIT.plusSeconds(10).toNanos());
+ }
+
+ /** The roots of a client, as the server asks for them, with the client's
answer given. */
+ static final class FakeRoots implements Roots {
+
+ final AtomicInteger asked = new AtomicInteger();
+ private final boolean supported;
+ private final Supplier<Uni<List<Root>>> answer;
+
+ FakeRoots(boolean supported, Supplier<Uni<List<Root>>> answer) {
+ this.supported = supported;
+ this.answer = answer;
+ }
+
+ static FakeRoots answering(List<Root> roots) {
+ return new FakeRoots(true, () -> Uni.createFrom().item(roots));
+ }
+
+ @Override
+ public boolean isSupported() {
+ return supported;
+ }
+
+ @Override
+ public Uni<List<Root>> list() {
+ asked.incrementAndGet();
+ return answer.get();
+ }
+
+ @Override
+ public boolean isServerInitiatedRequestSupported() {
+ return true;
+ }
+
+ @Override
+ public InputResponses inputResponses() {
+ return null;
+ }
+
+ @Override
+ public String requestState() {
+ return null;
+ }
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java
index 9559b8f780ed..8b4b05ddb024 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java
@@ -29,6 +29,7 @@ import java.util.Map;
import io.quarkiverse.mcp.server.McpConnection;
import io.quarkiverse.mcp.server.MetaField;
+import io.quarkiverse.mcp.server.Roots;
import io.quarkiverse.mcp.server.Tool;
import io.quarkiverse.mcp.server.ToolArg;
import io.quarkiverse.mcp.server.ToolCallException;
@@ -92,21 +93,21 @@ class AuthoringToolsTest {
@Test
void filesAreReadAndWrittenInTheGivenDirectory(@TempDir Path dir) throws
Exception {
String route = "- route:\n from:\n uri: timer:tick\n
steps:\n - log:\n message: hi\n";
- JsonObject written = tools.camel_write_file(dir.toString(),
"demo.camel.yaml", route, null);
+ JsonObject written = tools.camel_write_file(null, dir.toString(),
"demo.camel.yaml", route, null);
assertThat(written.getString("status")).isEqualTo("created");
assertThat(Files.readString(dir.resolve("demo.camel.yaml"),
StandardCharsets.UTF_8)).isEqualTo(route);
- JsonObject invalid = tools.camel_write_file(dir.toString(),
"demo.camel.yaml",
+ JsonObject invalid = tools.camel_write_file(null, dir.toString(),
"demo.camel.yaml",
route.replace("message", "mesage"), null);
assertThat(invalid.getString("status")).isEqualTo("invalid");
assertThat(Files.readString(dir.resolve("demo.camel.yaml"),
StandardCharsets.UTF_8)).isEqualTo(route);
- JsonObject listed = tools.camel_get_files(dir.toString(), null);
+ JsonObject listed = tools.camel_get_files(null, dir.toString(), null);
assertThat(listed.getInteger("totalFiles")).isEqualTo(1);
- assertThat(tools.camel_get_files(dir.toString(),
"demo.camel.yaml").getString("content")).isEqualTo(route);
- assertThat(tools.camel_validate_source(null, dir.toString(),
"demo.camel.yaml", null, null).getBoolean("valid"))
+ assertThat(tools.camel_get_files(null, dir.toString(),
"demo.camel.yaml").getString("content")).isEqualTo(route);
+ assertThat(tools.camel_validate_source(null, null, dir.toString(),
"demo.camel.yaml", null, null).getBoolean("valid"))
.isTrue();
- assertThatThrownBy(() -> tools.camel_get_files(null, null))
+ assertThatThrownBy(() -> tools.camel_get_files(null, null, null))
.isInstanceOf(ToolCallException.class).hasMessageContaining("directory is
required");
}
@@ -117,7 +118,7 @@ class AuthoringToolsTest {
+ " - log:\n message: bye\n";
Files.writeString(dir.resolve("demo.camel.yaml"), route,
StandardCharsets.UTF_8);
- JsonObject edited = tools.camel_edit_file(dir.toString(),
"demo.camel.yaml", null, null,
+ JsonObject edited = tools.camel_edit_file(null, dir.toString(),
"demo.camel.yaml", null, null,
List.of(new AuthoringTools.FileEdit("message: hi", "message:
hello"),
new AuthoringTools.FileEdit("message: bye", "message:
goodbye")),
null);
@@ -153,9 +154,10 @@ class AuthoringToolsTest {
}
ToolDescriptor td = ToolRegistry.findTool(m.getName());
List<ToolDescriptor.Param> params = td.params();
- // the connection is injected by the server, not an argument of
the tool
+ // the connection and the roots are injected by the server, not
arguments of the tool
Parameter[] args = Arrays.stream(m.getParameters())
- .filter(p -> p.getType() !=
McpConnection.class).toArray(Parameter[]::new);
+ .filter(p -> p.getType() != McpConnection.class &&
p.getType() != Roots.class)
+ .toArray(Parameter[]::new);
assertThat(args).as(m.getName() + " has one argument per
descriptor parameter").hasSize(params.size());
for (int i = 0; i < args.length; i++) {
ToolArg arg = args[i].getAnnotation(ToolArg.class);
@@ -218,16 +220,16 @@ class AuthoringToolsTest {
McpConnection agent = connection("agent-1");
String content = "- from:\n uri: timer:x\n steps:\n - to:
log:x\n";
- JsonObject first = counted.camel_validate_source(agent, null,
"route.camel.yaml", content, null);
- JsonObject second = counted.camel_validate_source(agent, null,
"route.camel.yaml", content, null);
- JsonObject third = counted.camel_validate_source(agent, null,
"route.camel.yaml", content, null);
+ JsonObject first = counted.camel_validate_source(agent, null, null,
"route.camel.yaml", content, null);
+ JsonObject second = counted.camel_validate_source(agent, null, null,
"route.camel.yaml", content, null);
+ JsonObject third = counted.camel_validate_source(agent, null, null,
"route.camel.yaml", content, null);
assertThat(first.get("repeated")).isNull();
assertThat(second.get("repeated")).isNull();
assertThat(third.getBoolean("repeated")).isTrue();
assertThat(third.getString("note")).contains("camel_validate_source",
"change the line the error names");
// changed content is a new question
- assertThat(counted.camel_validate_source(agent, null,
"route.camel.yaml", content + "\n", null)
+ assertThat(counted.camel_validate_source(agent, null, null,
"route.camel.yaml", content + "\n", null)
.get("repeated")).isNull();
}
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityInterceptorTest.java
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityInterceptorTest.java
index bd7718ffa7a6..f59dea61bd79 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityInterceptorTest.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityInterceptorTest.java
@@ -28,6 +28,7 @@ import java.util.Optional;
import jakarta.interceptor.InvocationContext;
+import io.quarkiverse.mcp.server.Roots;
import io.quarkiverse.mcp.server.ToolCallException;
import org.apache.camel.util.json.JsonObject;
import org.junit.jupiter.api.Test;
@@ -133,9 +134,10 @@ class McpSecurityInterceptorTest {
private static Object writeFile(McpSecurityInterceptor interceptor, Path
dir, String content) throws Exception {
AuthoringTools tools = new AuthoringTools();
tools.securityConfig = interceptor.config;
- Method method = AuthoringTools.class.getMethod("camel_write_file",
String.class, String.class, String.class,
- String.class);
- Object result = interceptor.intercept(new Invocation(tools, method,
dir.toString(), "notes.txt", content, null));
+ Method method = AuthoringTools.class.getMethod("camel_write_file",
Roots.class, String.class, String.class,
+ String.class, String.class);
+ Object result = interceptor.intercept(
+ new Invocation(tools, method, null, dir.toString(),
"notes.txt", content, null));
assertThat(((JsonObject) result).getString("status")).isIn("created",
"overwritten");
return result;
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java
index d2823a0a6df1..ebd0f31fa825 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java
@@ -397,7 +397,9 @@ class McpFacadeWriteFileTest {
assertEquals("error", facade.writeFile("demo", "../escape.yaml", "x",
true).getString("status"));
assertEquals("error", facade.writeFile("demo", "sub/../../dir.yaml",
"x", true).getString("status"));
- assertEquals("error", facade.writeFile("demo",
dir.resolve("abs.yaml").toString(), "x", true).getString("status"));
+ // an absolute path is taken only inside the source directory
(CAMEL-26398)
+ assertEquals("error",
+ facade.writeFile("demo",
dir.getParent().resolve("abs.yaml").toString(), "x", true).getString("status"));
assertEquals("error", facade.writeFile("demo", "", "x",
true).getString("status"));
assertEquals("error", facade.writeFile("demo", "demo.camel.yaml",
null, true).getString("status"));
assertEquals("error", facade.writeFile("nope", "demo.camel.yaml", "x",
true).getString("status"));
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiFileToolsTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiFileToolsTest.java
index 7aeb6792dcef..040f065e690f 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiFileToolsTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiFileToolsTest.java
@@ -26,6 +26,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* The file tools write to the source directory of an integration, after the
user confirmed the change, also when the
@@ -99,6 +100,40 @@ class TuiFileToolsTest {
assertThat(elsewhere.resolve("demo.camel.yaml")).doesNotExist();
}
+ @Test
+ void
anAbsoluteFileInsideTheIntegrationsDirectoryIsWrittenAfterTheUserConfirms()
throws Exception {
+ // CAMEL-26398: an absolute path inside the source directory is taken
relative to it
+ Path project = project();
+ McpFacadeWriteFileTest.ConfirmingBridge bridge = new
McpFacadeWriteFileTest.ConfirmingBridge(true);
+ TuiToolRegistry registry = new
TuiToolRegistry(McpFacadeWriteFileTest.facade(project, false, bridge));
+
+ JsonObject result = call(registry, "camel_write_file",
+ Map.of("name", "demo", "file",
project.resolve("demo.camel.yaml").toString(), "content", NEW));
+
+ assertThat(result.getString("status")).isEqualTo("overwritten");
+ assertThat(bridge.asked).isEqualTo(1);
+
assertThat(Files.readString(project.resolve("demo.camel.yaml"))).isEqualTo(NEW);
+ }
+
+ @Test
+ void anAbsoluteFileOutsideTheIntegrationsDirectoryIsNotWritten() throws
Exception {
+ Path project = project();
+ Path elsewhere =
Files.createDirectories(tempDir.resolve("elsewhere")).resolve("demo.camel.yaml");
+ McpFacadeWriteFileTest.ConfirmingBridge bridge = new
McpFacadeWriteFileTest.ConfirmingBridge(true);
+ TuiToolRegistry registry = new
TuiToolRegistry(McpFacadeWriteFileTest.facade(project, false, bridge));
+
+ JsonObject result = call(registry, "camel_write_file",
+ Map.of("name", "demo", "file", elsewhere.toString(),
"content", NEW));
+ assertThat(result.getString("status")).isEqualTo("error");
+ assertThat(result.getString("error")).contains("is not inside the
directory");
+ // nor with no integration named: an absolute file never names the
directory
+ assertThatThrownBy(() ->
registry.executeSharedWithTuiExtras("camel_write_file",
+ Map.of("file", elsewhere.toString(), "content",
NEW))).hasMessageStartingWith("directory is required");
+
+ assertThat(bridge.asked).isZero();
+ assertThat(elsewhere).doesNotExist();
+ }
+
private Path project() throws Exception {
Path project = Files.createDirectories(tempDir.resolve("project"));
Files.writeString(project.resolve("demo.camel.yaml"), OLD);