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 a5cd06d52563 CAMEL-25641: camel-jbang-mcp - every tool class is 
@McpSecured, with a test that keeps it so (#27812)
a5cd06d52563 is described below

commit a5cd06d525632e36745906f7e091f5975ec858d1
Author: Federico Mariani <[email protected]>
AuthorDate: Wed Oct 14 14:02:32 2026 +0200

    CAMEL-25641: camel-jbang-mcp - every tool class is @McpSecured, with a test 
that keeps it so (#27812)
    
    * CAMEL-25641: camel-jbang-mcp - every tool class is @McpSecured, with a 
test that keeps it so
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * CAMEL-25641: an argument over the limit is rejected instead of cut, write 
tools refuse redacted content
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../modules/ROOT/pages/camel-jbang-mcp.adoc        |   3 +
 .../core/commands/mcp/AiPipelineScaffoldTools.java |   1 +
 .../dsl/jbang/core/commands/mcp/AiTraceTools.java  |   1 +
 .../jbang/core/commands/mcp/AuthoringTools.java    |  38 +++++-
 .../commands/mcp/DependencySecurityAuditTools.java |   1 +
 .../jbang/core/commands/mcp/McpSecurityConfig.java |   1 +
 .../core/commands/mcp/McpSecurityInterceptor.java  |  19 ++-
 .../core/commands/mcp/RouteCostEstimateTools.java  |   1 +
 .../jbang/core/commands/mcp/SecurityScanTools.java |   1 +
 .../src/main/resources/application.properties      |   2 +
 .../commands/mcp/McpSecuredToolClassesTest.java    |  78 ++++++++++++
 .../commands/mcp/McpSecurityInterceptorTest.java   | 132 +++++++++++++++++++++
 12 files changed, 275 insertions(+), 3 deletions(-)

diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
index 6b6293195090..f8e1b9bac1b5 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -350,6 +350,9 @@ project `directory` as an argument, the runtime tools take 
the integration `name
   plain file name in the directory is accepted. 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,
+  and with `camel.mcp.security.redaction.enabled=true` this tool and 
`camel_edit_file` refuse content containing
+  the `+***REDACTED***+` mask that `camel_get_files` shows in place of a 
secret, so the mask never overwrites it.
   When an integration runs in dev mode from the directory (as `camel_run` 
starts it), the answer says how it
   reloaded the file and what its routes logged right after, so the agent sees 
the result of the change, such as
   an empty value its route now produces, without a call to `camel_get_log`.
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AiPipelineScaffoldTools.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AiPipelineScaffoldTools.java
index e88da86a6990..807733a151e0 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AiPipelineScaffoldTools.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AiPipelineScaffoldTools.java
@@ -28,6 +28,7 @@ import io.quarkiverse.mcp.server.ToolCallException;
  * Generates runnable YAML DSL routes that combine document processing 
(Docling, Textract) with AI services (Bedrock)
  * for common use cases: summarization, extraction, RAG, and classification.
  */
+@McpSecured
 @ApplicationScoped
 public class AiPipelineScaffoldTools {
 
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AiTraceTools.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AiTraceTools.java
index 000f7bea18ef..b6ea5ff5b53c 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AiTraceTools.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AiTraceTools.java
@@ -41,6 +41,7 @@ import org.jboss.logging.Logger;
  * token usage, model IDs, guardrail outcomes, completion reasons, and 
per-component latency for AI components (Bedrock,
  * LangChain4j, Docling, Textract, OpenAI, KServe, DJL, TensorFlow Serving, 
HuggingFace).
  */
+@McpSecured
 @ApplicationScoped
 public class AiTraceTools {
 
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 e854a23641ca..a7232bb0003d 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
@@ -37,13 +37,15 @@ import org.apache.camel.util.json.JsonObject;
 /**
  * The neutral Camel authoring tools shared with {@code camel tui --mcp} 
(CAMEL-24695): thin MCP wrappers over the
  * {@link ToolRegistry}, so an AI agent gets the same tools, names and answers 
through either server. The logic lives in
- * camel-jbang-core ({@code AuthoringTools} there); these methods only carry 
the MCP annotations the security
- * interceptor and access filter work on, and pass the arguments through.
+ * camel-jbang-core ({@code AuthoringTools} there); these methods only carry 
the MCP annotations the access filter works
+ * on and pass the arguments through. The class is {@link McpSecured}, so 
every call goes through the security
+ * interceptor (audit, argument sanitisation, secret redaction of the answer).
  * <p>
  * Writing a file is validated first and refused when the content is invalid, 
but nobody is asked before the write: the
  * MCP client (Claude Code, Cursor, and the others ask before a non read-only 
tool runs) is where the human sits. The
  * {@code read-only} access level hides the tools that write, run and control.
  */
+@McpSecured
 @ApplicationScoped
 public class AuthoringTools {
 
@@ -54,6 +56,9 @@ public class AuthoringTools {
     @Inject
     RepeatedCallSessions repeatedCalls;
 
+    @Inject
+    McpSecurityConfig securityConfig;
+
     @Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint 
= false, openWorldHint = false),
           description = "Camel catalog documentation of a component, data 
format, language, EIP, Kamelet, built-in bean or the "
                         + "Java API (description, options, Maven coordinates), 
with the URI rules of a component. For "
@@ -168,6 +173,7 @@ public class AuthoringTools {
                      required = true) String file,
             @ToolArg(description = "The complete new content", required = 
true) String content,
             @ToolArg(description = VERSION_DESC, required = false) String 
camelVersion) {
+        refuseRedacted(content);
         return call("camel_write_file", args("directory", directory, "file", 
file, "content", content,
                 "camelVersion", camelVersion));
     }
@@ -187,10 +193,38 @@ public class AuthoringTools {
             @ToolArg(description = 
org.apache.camel.dsl.jbang.core.commands.ai.AuthoringTools.EDITS_DESC,
                      required = false) List<FileEdit> edits,
             @ToolArg(description = VERSION_DESC, required = false) String 
camelVersion) {
+        refuseRedacted(find, replace);
+        if (edits != null) {
+            for (FileEdit edit : edits) {
+                if (edit != null) {
+                    refuseRedacted(edit.find(), edit.replace());
+                }
+            }
+        }
         return call("camel_edit_file", args("directory", directory, "file", 
file, "find", find, "replace", replace,
                 "edits", editsJson(edits), "camelVersion", camelVersion));
     }
 
+    /**
+     * With secret redaction on, camel_get_files answers a secret as the 
redaction mask; written back, the mask would
+     * replace the real secret in the file. So a write or an edit that carries 
the mask is refused.
+     */
+    private void refuseRedacted(String... texts) {
+        if (securityConfig == null || !securityConfig.isEnabled() || 
!securityConfig.isRedactionEnabled()) {
+            return;
+        }
+        for (String text : texts) {
+            if (text != null && text.contains(McpSecretRedactor.REDACTED)) {
+                throw new ToolCallException(
+                        "The content contains " + McpSecretRedactor.REDACTED + 
", the mask secret redaction "
+                                            + 
"(camel.mcp.security.redaction.enabled) shows in place of a secret; "
+                                            + "written back it would replace 
the real secret, so nothing was "
+                                            + "written. Change only the lines 
without the mask with "
+                                            + "camel_edit_file, and leave the 
secret itself to a human.");
+            }
+        }
+    }
+
     /** One change of camel_edit_file's edits: the text to find as it stands 
in the file, and what to put there. */
     public record FileEdit(String find, String replace) {
     }
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/DependencySecurityAuditTools.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/DependencySecurityAuditTools.java
index 8a8c3f522e2f..43ca52fcd94a 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/DependencySecurityAuditTools.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/DependencySecurityAuditTools.java
@@ -39,6 +39,7 @@ import org.apache.camel.tooling.model.SecurityAdvisoryModel;
  * tool cross-references a project's declared dependencies with the Camel 
security advisory database and component
  * metadata to produce actionable vulnerability findings per artifact.
  */
+@McpSecured
 @ApplicationScoped
 public class DependencySecurityAuditTools {
 
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityConfig.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityConfig.java
index 0648afa5dbac..6e7b233b93c0 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityConfig.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityConfig.java
@@ -59,6 +59,7 @@ public class McpSecurityConfig {
     @ConfigProperty(name = "camel.mcp.security.redaction.patterns")
     Optional<String> redactionPatterns;
 
+    /** A tool call with a string argument longer than this is rejected, never 
cut. */
     @ConfigProperty(name = "camel.mcp.security.max-argument-length", 
defaultValue = "10000")
     int maxArgumentLength;
 
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityInterceptor.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityInterceptor.java
index bbc59e988379..e16005992711 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityInterceptor.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityInterceptor.java
@@ -17,6 +17,7 @@
 package org.apache.camel.dsl.jbang.core.commands.mcp;
 
 import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
 import java.util.List;
 import java.util.Map;
 
@@ -27,6 +28,8 @@ import jakarta.interceptor.Interceptor;
 import jakarta.interceptor.InvocationContext;
 
 import io.quarkiverse.mcp.server.Tool;
+import io.quarkiverse.mcp.server.ToolArg;
+import io.quarkiverse.mcp.server.ToolCallException;
 
 /**
  * CDI interceptor for the MCP security execution layer.
@@ -128,7 +131,11 @@ public class McpSecurityInterceptor {
             if (params[i] instanceof String s) {
                 String clean = stripControlChars(s);
                 if (clean.length() > maxLen) {
-                    clean = clean.substring(0, maxLen);
+                    // rejected, never cut: a shortened file content or 
replacement would be written as if complete
+                    throw new ToolCallException(
+                            "Argument '" + parameterName(ctx.getMethod(), i) + 
"' is " + clean.length()
+                                                + " characters long, over the 
limit of " + maxLen
+                                                + " 
(camel.mcp.security.max-argument-length); nothing was done");
                 }
                 if (!clean.equals(s)) {
                     params[i] = clean;
@@ -142,6 +149,16 @@ public class McpSecurityInterceptor {
         }
     }
 
+    /** The name the client gives the argument (its {@code @ToolArg} name, 
else the parameter name). */
+    static String parameterName(Method method, int index) {
+        Parameter parameter = method.getParameters()[index];
+        ToolArg arg = parameter.getAnnotation(ToolArg.class);
+        if (arg != null && !ToolArg.ELEMENT_NAME.equals(arg.name())) {
+            return arg.name();
+        }
+        return parameter.getName();
+    }
+
     static String stripControlChars(String input) {
         if (input == null) {
             return null;
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteCostEstimateTools.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteCostEstimateTools.java
index 65652b8c48e9..f74e01822ed4 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteCostEstimateTools.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteCostEstimateTools.java
@@ -40,6 +40,7 @@ import io.quarkiverse.mcp.server.ToolCallException;
  * Cost data is approximate and based on published AWS pricing as of 2025. 
Actual costs depend on model, region, and
  * volume tiers.
  */
+@McpSecured
 @ApplicationScoped
 public class RouteCostEstimateTools {
 
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/SecurityScanTools.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/SecurityScanTools.java
index 8c720d85c957..5c0d0421ec76 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/SecurityScanTools.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/SecurityScanTools.java
@@ -39,6 +39,7 @@ import org.apache.camel.util.SecurityUtils;
  * Distinct from {@link HardenTools} which provides general security context 
and CVE advisories — this tool performs
  * line-by-line static analysis to find specific violations.
  */
+@McpSecured
 @ApplicationScoped
 public class SecurityScanTools {
 
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 ada695b372ab..24fdd0a81968 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
@@ -44,8 +44,10 @@ quarkus.jackson.serialization-inclusion=non-null
 # camel.mcp.security.access-level=admin
 # camel.mcp.security.audit.enabled=true
 # camel.mcp.security.audit.include-arguments=true
+## With redaction on, camel_write_file and camel_edit_file refuse content that 
carries the ***REDACTED*** mask.
 # camel.mcp.security.redaction.enabled=true
 # camel.mcp.security.redaction.patterns=
+## A tool call with a string argument longer than this is rejected (never cut).
 # camel.mcp.security.max-argument-length=10000
 
 ## Audit log routing (optional — route to a separate file or syslog)
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecuredToolClassesTest.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecuredToolClassesTest.java
new file mode 100644
index 000000000000..04df44cd56a0
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecuredToolClassesTest.java
@@ -0,0 +1,78 @@
+/*
+ * 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.io.IOException;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Stream;
+
+import io.quarkiverse.mcp.server.Tool;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Every class of the MCP module that exposes a {@link Tool} must carry {@link 
McpSecured}: the security interceptor
+ * (audit, argument sanitisation, secret redaction) is bound by that 
annotation only, so a tool class without it
+ * silently skips all of it.
+ */
+class McpSecuredToolClassesTest {
+
+    @Test
+    void everyToolClassIsMcpSecured() throws Exception {
+        List<Class<?>> toolClasses = toolClasses();
+
+        // guard against a scan that finds nothing and makes the check below 
vacuous
+        
assertThat(toolClasses).hasSizeGreaterThan(20).contains(AuthoringTools.class, 
CatalogTools.class);
+        assertThat(toolClasses)
+                .filteredOn(c -> !c.isAnnotationPresent(McpSecured.class))
+                .as("tool classes without @McpSecured")
+                .isEmpty();
+    }
+
+    private static List<Class<?>> toolClasses() throws IOException, 
URISyntaxException {
+        URL url = 
McpSecured.class.getResource(McpSecured.class.getSimpleName() + ".class");
+        assertThat(url).isNotNull();
+        assertThat(url.getProtocol()).as("the main classes are scanned as a 
directory").isEqualTo("file");
+        Path dir = Path.of(url.toURI()).getParent();
+        String pkg = McpSecured.class.getPackageName();
+        try (Stream<Path> files = Files.list(dir)) {
+            return files.map(p -> p.getFileName().toString())
+                    .filter(n -> n.endsWith(".class") && !n.contains("$"))
+                    .<Class<?>> map(n -> load(pkg + "." + n.substring(0, 
n.length() - ".class".length())))
+                    .filter(McpSecuredToolClassesTest::declaresTool)
+                    .toList();
+        }
+    }
+
+    private static boolean declaresTool(Class<?> clazz) {
+        return Arrays.stream(clazz.getDeclaredMethods()).anyMatch(m -> 
m.isAnnotationPresent(Tool.class));
+    }
+
+    private static Class<?> load(String name) {
+        try {
+            return Class.forName(name, false, 
McpSecuredToolClassesTest.class.getClassLoader());
+        } catch (ClassNotFoundException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+}
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 64c7eb586072..01360328e6d8 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
@@ -16,9 +16,25 @@
  */
 package org.apache.camel.dsl.jbang.core.commands.mcp;
 
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import jakarta.interceptor.InvocationContext;
+
+import io.quarkiverse.mcp.server.ToolCallException;
+import org.apache.camel.util.json.JsonObject;
 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;
 
 class McpSecurityInterceptorTest {
 
@@ -64,4 +80,120 @@ class McpSecurityInterceptorTest {
         assertThat(readOnly.permits(false, false)).isFalse();
         assertThat(readOnly.permits(true, false)).isTrue();
     }
+
+    @Test
+    void overLongArgumentIsRejectedAndTheFileLeftIntact(@TempDir Path dir) 
throws Exception {
+        Files.writeString(dir.resolve("notes.txt"), "original");
+        String content = 
"a".repeat(McpSecurityConfig.DEFAULT_MAX_ARGUMENT_LENGTH) + "TAIL_MUST_SURVIVE";
+
+        assertThatThrownBy(() -> writeFile(securedTools(false), dir, content))
+                .isInstanceOf(ToolCallException.class)
+                .hasMessageContaining("'content'")
+                
.hasMessageContaining(String.valueOf(McpSecurityConfig.DEFAULT_MAX_ARGUMENT_LENGTH));
+        
assertThat(Files.readString(dir.resolve("notes.txt"))).isEqualTo("original");
+    }
+
+    @Test
+    void argumentWithinTheLimitIsWrittenInFull(@TempDir Path dir) throws 
Exception {
+        String content = 
"a".repeat(McpSecurityConfig.DEFAULT_MAX_ARGUMENT_LENGTH - 4) + "TAIL";
+
+        writeFile(securedTools(false), dir, content);
+
+        
assertThat(Files.readAllBytes(dir.resolve("notes.txt"))).isEqualTo(content.getBytes(StandardCharsets.UTF_8));
+    }
+
+    @Test
+    void redactedContentIsNotWrittenBack(@TempDir Path dir) throws Exception {
+        Files.writeString(dir.resolve("notes.txt"), "password=hunter2");
+
+        assertThatThrownBy(() -> writeFile(securedTools(true), dir, 
"password=" + McpSecretRedactor.REDACTED))
+                .isInstanceOf(ToolCallException.class)
+                .hasMessageContaining(McpSecretRedactor.REDACTED);
+        
assertThat(Files.readString(dir.resolve("notes.txt"))).isEqualTo("password=hunter2");
+    }
+
+    /** An interceptor with security on, as CDI would build it, around the 
shared authoring tools. */
+    private static McpSecurityInterceptor securedTools(boolean redaction) {
+        McpSecurityConfig config = new McpSecurityConfig();
+        config.enabled = true;
+        config.accessLevel = "admin";
+        config.redactionEnabled = redaction;
+        config.redactionPatterns = Optional.empty();
+        config.maxArgumentLength = 
McpSecurityConfig.DEFAULT_MAX_ARGUMENT_LENGTH;
+        McpSecretRedactor redactor = new McpSecretRedactor();
+        redactor.config = config;
+        McpSecurityInterceptor interceptor = new McpSecurityInterceptor();
+        interceptor.config = config;
+        interceptor.auditLogger = new McpAuditLogger();
+        interceptor.redactor = redactor;
+        return interceptor;
+    }
+
+    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));
+        assertThat(((JsonObject) result).getString("status")).isIn("created", 
"overwritten");
+        return result;
+    }
+
+    /** The invocation of one tool method, as the CDI container hands it to 
the interceptor. */
+    private static final class Invocation implements InvocationContext {
+        private final Object target;
+        private final Method method;
+        private Object[] parameters;
+        private final Map<String, Object> contextData = new HashMap<>();
+
+        Invocation(Object target, Method method, Object... parameters) {
+            this.target = target;
+            this.method = method;
+            this.parameters = parameters;
+        }
+
+        @Override
+        public Object getTarget() {
+            return target;
+        }
+
+        @Override
+        public Object getTimer() {
+            return null;
+        }
+
+        @Override
+        public Method getMethod() {
+            return method;
+        }
+
+        @Override
+        public Constructor<?> getConstructor() {
+            return null;
+        }
+
+        @Override
+        public Object[] getParameters() {
+            return parameters;
+        }
+
+        @Override
+        public void setParameters(Object[] params) {
+            this.parameters = params;
+        }
+
+        @Override
+        public Map<String, Object> getContextData() {
+            return contextData;
+        }
+
+        @Override
+        public Object proceed() throws Exception {
+            try {
+                return method.invoke(target, parameters);
+            } catch (InvocationTargetException e) {
+                throw (Exception) e.getCause();
+            }
+        }
+    }
 }

Reply via email to