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);

Reply via email to