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();
+ }
+ }
+ }
}