This is an automated email from the ASF dual-hosted git repository. fmariani pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel.git
commit ec4b2f6acbb91dbcca096805887a341b2a375376 Author: Croway <[email protected]> AuthorDate: Wed Mar 11 14:53:40 2026 +0100 CAMEL-23175: MCP support, Tool bean discovery, Tool context, StructuredOutputValidation, Tool selection by Name --- .../camel-spring-ai/camel-spring-ai-chat/pom.xml | 14 ++ .../chat/SpringAiChatEndpointConfigurer.java | 50 ++++- .../chat/SpringAiChatEndpointUriFactory.java | 13 +- .../component/springai/chat/spring-ai-chat.json | 57 +++--- .../src/main/docs/spring-ai-chat-component.adoc | 228 +++++++++++++++++++++ .../springai/chat/SpringAiChatConfiguration.java | 128 ++++++++++++ .../springai/chat/SpringAiChatConstants.java | 8 + .../springai/chat/SpringAiChatProducer.java | 89 ++++++++ .../springai/chat/mcp/SpringAiChatMcpManager.java | 186 +++++++++++++++++ .../component/springai/chat/SpringAiChatMcpIT.java | 88 ++++++++ .../springai/chat/SpringAiChatMcpSseIT.java | 74 +++++++ .../SpringAiChatStructuredOutputValidationIT.java | 129 ++++++++++++ .../chat/SpringAiChatToolBeanDiscoveryIT.java | 119 +++++++++++ .../springai/chat/SpringAiChatToolContextIT.java | 105 ++++++++++ .../src/main/docs/spring-ai-image-component.adoc | 2 +- .../services/McpEverythingSseService.java | 26 +++ 16 files changed, 1288 insertions(+), 28 deletions(-) diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/pom.xml b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/pom.xml index 4e8d6bd38885..f222fe2ce036 100644 --- a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/pom.xml +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/pom.xml @@ -75,6 +75,13 @@ <artifactId>spring-ai-advisors-vector-store</artifactId> </dependency> + <!-- Spring AI MCP integration for Model Context Protocol client support. + The MCP SDK is included transitively via spring-ai-mcp. --> + <dependency> + <groupId>org.springframework.ai</groupId> + <artifactId>spring-ai-mcp</artifactId> + </dependency> + <!-- Test dependencies --> <dependency> <groupId>org.apache.camel</groupId> @@ -110,6 +117,13 @@ <version>${project.version}</version> <scope>test</scope> </dependency> + <!-- MCP Everything Server test infrastructure --> + <dependency> + <groupId>org.apache.camel</groupId> + <artifactId>camel-test-infra-mcp-everything</artifactId> + <version>${project.version}</version> + <scope>test</scope> + </dependency> </dependencies> </project> diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/generated/java/org/apache/camel/component/springai/chat/SpringAiChatEndpointConfigurer.java b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/generated/java/org/apache/camel/component/springai/chat/SpringAiChatEndpointConfigurer.java index 03254f1979bb..f73ad56a2006 100644 --- a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/generated/java/org/apache/camel/component/springai/chat/SpringAiChatEndpointConfigurer.java +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/generated/java/org/apache/camel/component/springai/chat/SpringAiChatEndpointConfigurer.java @@ -42,6 +42,10 @@ public class SpringAiChatEndpointConfigurer extends PropertyConfigurerSupport im case "maxFileSize": target.getConfiguration().setMaxFileSize(property(camelContext, long.class, value)); return true; case "maxtokens": case "maxTokens": target.getConfiguration().setMaxTokens(property(camelContext, java.lang.Integer.class, value)); return true; + case "mcpserver": + case "mcpServer": target.getConfiguration().setMcpServer(property(camelContext, java.util.Map.class, value)); return true; + case "mcptimeout": + case "mcpTimeout": target.getConfiguration().setMcpTimeout(property(camelContext, int.class, value)); return true; case "outputclass": case "outputClass": target.getConfiguration().setOutputClass(property(camelContext, java.lang.Class.class, value)); return true; case "outputformat": @@ -58,12 +62,22 @@ public class SpringAiChatEndpointConfigurer extends PropertyConfigurerSupport im case "similarityThreshold": target.getConfiguration().setSimilarityThreshold(property(camelContext, double.class, value)); return true; case "structuredoutputconverter": case "structuredOutputConverter": target.getConfiguration().setStructuredOutputConverter(property(camelContext, org.springframework.ai.converter.StructuredOutputConverter.class, value)); return true; + case "structuredoutputvalidation": + case "structuredOutputValidation": target.getConfiguration().setStructuredOutputValidation(property(camelContext, boolean.class, value)); return true; + case "structuredoutputvalidationmaxattempts": + case "structuredOutputValidationMaxAttempts": target.getConfiguration().setStructuredOutputValidationMaxAttempts(property(camelContext, int.class, value)); return true; case "systemmessage": case "systemMessage": target.getConfiguration().setSystemMessage(property(camelContext, java.lang.String.class, value)); return true; case "systemmetadata": case "systemMetadata": target.getConfiguration().setSystemMetadata(property(camelContext, java.util.Map.class, value)); return true; case "tags": target.getConfiguration().setTags(property(camelContext, java.lang.String.class, value)); return true; case "temperature": target.getConfiguration().setTemperature(property(camelContext, java.lang.Double.class, value)); return true; + case "toolcallbacks": + case "toolCallbacks": target.getConfiguration().setToolCallbacks(property(camelContext, java.util.List.class, value)); return true; + case "toolcontext": + case "toolContext": target.getConfiguration().setToolContext(property(camelContext, java.util.Map.class, value)); return true; + case "toolnames": + case "toolNames": target.getConfiguration().setToolNames(property(camelContext, java.lang.String.class, value)); return true; case "topk": case "topK": target.getConfiguration().setTopK(property(camelContext, int.class, value)); return true; case "topksampling": @@ -82,7 +96,7 @@ public class SpringAiChatEndpointConfigurer extends PropertyConfigurerSupport im @Override public String[] getAutowiredNames() { - return new String[]{"chatClient", "chatMemory", "chatModel", "structuredOutputConverter", "vectorStore"}; + return new String[]{"chatClient", "chatMemory", "chatModel", "structuredOutputConverter", "toolCallbacks", "vectorStore"}; } @Override @@ -107,6 +121,10 @@ public class SpringAiChatEndpointConfigurer extends PropertyConfigurerSupport im case "maxFileSize": return long.class; case "maxtokens": case "maxTokens": return java.lang.Integer.class; + case "mcpserver": + case "mcpServer": return java.util.Map.class; + case "mcptimeout": + case "mcpTimeout": return int.class; case "outputclass": case "outputClass": return java.lang.Class.class; case "outputformat": @@ -123,12 +141,22 @@ public class SpringAiChatEndpointConfigurer extends PropertyConfigurerSupport im case "similarityThreshold": return double.class; case "structuredoutputconverter": case "structuredOutputConverter": return org.springframework.ai.converter.StructuredOutputConverter.class; + case "structuredoutputvalidation": + case "structuredOutputValidation": return boolean.class; + case "structuredoutputvalidationmaxattempts": + case "structuredOutputValidationMaxAttempts": return int.class; case "systemmessage": case "systemMessage": return java.lang.String.class; case "systemmetadata": case "systemMetadata": return java.util.Map.class; case "tags": return java.lang.String.class; case "temperature": return java.lang.Double.class; + case "toolcallbacks": + case "toolCallbacks": return java.util.List.class; + case "toolcontext": + case "toolContext": return java.util.Map.class; + case "toolnames": + case "toolNames": return java.lang.String.class; case "topk": case "topK": return int.class; case "topksampling": @@ -168,6 +196,10 @@ public class SpringAiChatEndpointConfigurer extends PropertyConfigurerSupport im case "maxFileSize": return target.getConfiguration().getMaxFileSize(); case "maxtokens": case "maxTokens": return target.getConfiguration().getMaxTokens(); + case "mcpserver": + case "mcpServer": return target.getConfiguration().getMcpServer(); + case "mcptimeout": + case "mcpTimeout": return target.getConfiguration().getMcpTimeout(); case "outputclass": case "outputClass": return target.getConfiguration().getOutputClass(); case "outputformat": @@ -184,12 +216,22 @@ public class SpringAiChatEndpointConfigurer extends PropertyConfigurerSupport im case "similarityThreshold": return target.getConfiguration().getSimilarityThreshold(); case "structuredoutputconverter": case "structuredOutputConverter": return target.getConfiguration().getStructuredOutputConverter(); + case "structuredoutputvalidation": + case "structuredOutputValidation": return target.getConfiguration().isStructuredOutputValidation(); + case "structuredoutputvalidationmaxattempts": + case "structuredOutputValidationMaxAttempts": return target.getConfiguration().getStructuredOutputValidationMaxAttempts(); case "systemmessage": case "systemMessage": return target.getConfiguration().getSystemMessage(); case "systemmetadata": case "systemMetadata": return target.getConfiguration().getSystemMetadata(); case "tags": return target.getConfiguration().getTags(); case "temperature": return target.getConfiguration().getTemperature(); + case "toolcallbacks": + case "toolCallbacks": return target.getConfiguration().getToolCallbacks(); + case "toolcontext": + case "toolContext": return target.getConfiguration().getToolContext(); + case "toolnames": + case "toolNames": return target.getConfiguration().getToolNames(); case "topk": case "topK": return target.getConfiguration().getTopK(); case "topksampling": @@ -212,12 +254,18 @@ public class SpringAiChatEndpointConfigurer extends PropertyConfigurerSupport im case "advisors": return org.springframework.ai.chat.client.advisor.api.Advisor.class; case "entityclass": case "entityClass": return java.lang.Object.class; + case "mcpserver": + case "mcpServer": return java.lang.Object.class; case "outputclass": case "outputClass": return java.lang.Object.class; case "structuredoutputconverter": case "structuredOutputConverter": return java.lang.Object.class; case "systemmetadata": case "systemMetadata": return java.lang.Object.class; + case "toolcallbacks": + case "toolCallbacks": return org.springframework.ai.tool.ToolCallback.class; + case "toolcontext": + case "toolContext": return java.lang.Object.class; case "usermetadata": case "userMetadata": return java.lang.Object.class; default: return null; diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/generated/java/org/apache/camel/component/springai/chat/SpringAiChatEndpointUriFactory.java b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/generated/java/org/apache/camel/component/springai/chat/SpringAiChatEndpointUriFactory.java index 32ad0c2615d4..149cec1daf61 100644 --- a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/generated/java/org/apache/camel/component/springai/chat/SpringAiChatEndpointUriFactory.java +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/generated/java/org/apache/camel/component/springai/chat/SpringAiChatEndpointUriFactory.java @@ -23,7 +23,7 @@ public class SpringAiChatEndpointUriFactory extends org.apache.camel.support.com private static final Set<String> SECRET_PROPERTY_NAMES; private static final Map<String, String> MULTI_VALUE_PREFIXES; static { - Set<String> props = new HashSet<>(29); + Set<String> props = new HashSet<>(36); props.add("advisors"); props.add("chatClient"); props.add("chatId"); @@ -35,6 +35,8 @@ public class SpringAiChatEndpointUriFactory extends org.apache.camel.support.com props.add("lazyStartProducer"); props.add("maxFileSize"); props.add("maxTokens"); + props.add("mcpServer"); + props.add("mcpTimeout"); props.add("outputClass"); props.add("outputFormat"); props.add("ragTemplate"); @@ -43,10 +45,15 @@ public class SpringAiChatEndpointUriFactory extends org.apache.camel.support.com props.add("safeguardSensitiveWords"); props.add("similarityThreshold"); props.add("structuredOutputConverter"); + props.add("structuredOutputValidation"); + props.add("structuredOutputValidationMaxAttempts"); props.add("systemMessage"); props.add("systemMetadata"); props.add("tags"); props.add("temperature"); + props.add("toolCallbacks"); + props.add("toolContext"); + props.add("toolNames"); props.add("topK"); props.add("topKSampling"); props.add("topP"); @@ -55,7 +62,9 @@ public class SpringAiChatEndpointUriFactory extends org.apache.camel.support.com props.add("vectorStore"); PROPERTY_NAMES = Collections.unmodifiableSet(props); SECRET_PROPERTY_NAMES = Collections.emptySet(); - MULTI_VALUE_PREFIXES = Collections.emptyMap(); + Map<String, String> prefixes = new HashMap<>(1); + prefixes.put("mcpServer", "mcpServer."); + MULTI_VALUE_PREFIXES = Collections.unmodifiableMap(prefixes); } @Override diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/generated/resources/META-INF/org/apache/camel/component/springai/chat/spring-ai-chat.json b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/generated/resources/META-INF/org/apache/camel/component/springai/chat/spring-ai-chat.json index 590077c9cdf5..0608321cf737 100644 --- a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/generated/resources/META-INF/org/apache/camel/component/springai/chat/spring-ai-chat.json +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/generated/resources/META-INF/org/apache/camel/component/springai/chat/spring-ai-chat.json @@ -58,7 +58,9 @@ "CamelSpringAiChatFinishReason": { "index": 26, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The reason why the chat response generation stopped (e.g., STOP, LENGTH, TOOL_CALLS)", "constantName": "org.apache.camel.component.springai.chat.SpringAiChatConstants#FINISH_REASON" }, "CamelSpringAiChatModelName": { "index": 27, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The name of the AI model used to generate the response", "constantName": "org.apache.camel.component.springai.chat.SpringAiChatConstants#MODEL_NAME" }, "CamelSpringAiChatResponseId": { "index": 28, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The unique ID of the chat response", "constantName": "org.apache.camel.component.springai.chat.SpringAiChatConstants#RESPONSE_ID" }, - "CamelSpringAiChatResponseMetadata": { "index": 29, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.Map<String, Object>", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Full response metadata as a Map containing all available metadata fields", "constantName": "org.apache.camel.component.springai.chat.SpringAiChatConstants#RESPONSE_METADATA" } + "CamelSpringAiChatResponseMetadata": { "index": 29, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.Map<String, Object>", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Full response metadata as a Map containing all available metadata fields", "constantName": "org.apache.camel.component.springai.chat.SpringAiChatConstants#RESPONSE_METADATA" }, + "CamelSpringAiChatToolNames": { "index": 30, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Comma-separated tool names for selecting tools by name via ToolCallbackResolver", "constantName": "org.apache.camel.component.springai.chat.SpringAiChatConstants#TOOL_NAMES" }, + "CamelSpringAiChatToolContext": { "index": 31, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.Map<String, Object>", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Context map to pass to tools during execution", "constantName": "org.apache.camel.component.springai.chat.SpringAiChatConstants#TOOL_CONTEXT" } }, "properties": { "chatId": { "index": 0, "kind": "path", "displayName": "Chat Id", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "The ID of the chat endpoint" }, @@ -67,28 +69,35 @@ "chatOperation": { "index": 3, "kind": "parameter", "displayName": "Chat Operation", "group": "producer", "label": "", "required": true, "type": "enum", "javaType": "org.apache.camel.component.springai.chat.SpringAiChatOperations", "enum": [ "CHAT_SINGLE_MESSAGE", "CHAT_SINGLE_MESSAGE_WITH_PROMPT", "CHAT_MULTIPLE_MESSAGES" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "CHAT_SINGLE_MESSAGE", "configurationClass": "org.apache.camel. [...] "systemMessage": { "index": 4, "kind": "parameter", "displayName": "System Message", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Default system message to set context for the conversation. Can be overridden by the CamelSprin [...] "tags": { "index": 5, "kind": "parameter", "displayName": "Tags", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Tags for discovering and calling Camel route tools" }, - "userMessage": { "index": 6, "kind": "parameter", "displayName": "User Message", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Default user message text for multimodal requests. Can be combined with media data in the message b [...] - "lazyStartProducer": { "index": 7, "kind": "parameter", "displayName": "Lazy Start Producer", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should be started lazy (on the first message). By starting lazy you can use this to allow CamelContext and routes to startup in situations where a produc [...] - "advisors": { "index": 8, "kind": "parameter", "displayName": "Advisors", "group": "advanced", "label": "advanced", "required": false, "type": "array", "javaType": "java.util.List<org.springframework.ai.chat.client.advisor.api.Advisor>", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "List of custom advisor [...] - "chatMemory": { "index": 9, "kind": "parameter", "displayName": "Chat Memory", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "org.springframework.ai.chat.memory.ChatMemory", "deprecated": false, "deprecationNote": "", "autowired": true, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "ChatMemory instance for maintaining conver [...] - "chatMemoryVectorStore": { "index": 10, "kind": "parameter", "displayName": "Chat Memory Vector Store", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "org.springframework.ai.vectorstore.VectorStore", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "VectorStore instance for maintaining c [...] - "entityClass": { "index": 11, "kind": "parameter", "displayName": "Entity Class", "group": "advanced", "label": "advanced", "required": false, "type": "string", "javaType": "java.lang.Class<java.lang.Object>", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "The Java class to use for entity response conversion using ChatClient.ent [...] - "maxFileSize": { "index": 12, "kind": "parameter", "displayName": "Max File Size", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 1048576, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Maximum file size in bytes for multimodal content (images, audio, PDFs, etc [...] - "maxTokens": { "index": 13, "kind": "parameter", "displayName": "Max Tokens", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "java.lang.Integer", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Maximum tokens in the response." }, - "outputClass": { "index": 14, "kind": "parameter", "displayName": "Output Class", "group": "advanced", "label": "advanced", "required": false, "type": "string", "javaType": "java.lang.Class<java.lang.Object>", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "The Java class to use for BEAN output format conversion. Required when ou [...] - "outputFormat": { "index": 15, "kind": "parameter", "displayName": "Output Format", "group": "advanced", "label": "advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "The output format for structured output conversion (BEAN, MAP, LIST). Used in conjunctio [...] - "structuredOutputConverter": { "index": 16, "kind": "parameter", "displayName": "Structured Output Converter", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "org.springframework.ai.converter.StructuredOutputConverter<java.lang.Object>", "deprecated": false, "deprecationNote": "", "autowired": true, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configurati [...] - "systemMetadata": { "index": 17, "kind": "parameter", "displayName": "System Metadata", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "java.util.Map<java.lang.String, java.lang.Object>", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Metadata to attach to system messages. This metadat [...] - "temperature": { "index": 18, "kind": "parameter", "displayName": "Temperature", "group": "advanced", "label": "advanced", "required": false, "type": "number", "javaType": "java.lang.Double", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Temperature parameter for response randomness (0.0-2.0)." }, - "topKSampling": { "index": 19, "kind": "parameter", "displayName": "Top KSampling", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "java.lang.Integer", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Top K parameter for sampling." }, - "topP": { "index": 20, "kind": "parameter", "displayName": "Top P", "group": "advanced", "label": "advanced", "required": false, "type": "number", "javaType": "java.lang.Double", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Top P parameter for nucleus sampling." }, - "userMetadata": { "index": 21, "kind": "parameter", "displayName": "User Metadata", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "java.util.Map<java.lang.String, java.lang.Object>", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Metadata to attach to user messages. This metadata can [...] - "ragTemplate": { "index": 22, "kind": "parameter", "displayName": "Rag Template", "group": "rag", "label": "rag", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "Context:\\n\\{context}\\n\\nQuestion: \\{question}", "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Template for formatting RAG (R [...] - "similarityThreshold": { "index": 23, "kind": "parameter", "displayName": "Similarity Threshold", "group": "rag", "label": "rag", "required": false, "type": "number", "javaType": "double", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 0.7, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Similarity threshold for RAG retrieval (default: 0.7)." }, - "topK": { "index": 24, "kind": "parameter", "displayName": "Top K", "group": "rag", "label": "rag", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 5, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Number of top documents to retrieve for RAG (default: 5)." }, - "vectorStore": { "index": 25, "kind": "parameter", "displayName": "Vector Store", "group": "rag", "label": "rag", "required": false, "type": "object", "javaType": "org.springframework.ai.vectorstore.VectorStore", "deprecated": false, "deprecationNote": "", "autowired": true, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "VectorStore for automatic RAG retrieval." }, - "safeguardFailureResponse": { "index": 26, "kind": "parameter", "displayName": "Safeguard Failure Response", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Failure response message for SafeGuard advisor when sensitive c [...] - "safeguardSensitiveWords": { "index": 27, "kind": "parameter", "displayName": "Safeguard Sensitive Words", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Comma-separated list of sensitive words for SafeGuard advisor. Wh [...] - "safeguardOrder": { "index": 28, "kind": "parameter", "displayName": "Safeguard Order", "group": "security (advanced)", "label": "security,advanced", "required": false, "type": "integer", "javaType": "java.lang.Integer", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Order of execution for SafeGuard advisor. Lower numbers execut [...] + "toolNames": { "index": 6, "kind": "parameter", "displayName": "Tool Names", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Comma-separated tool names for selecting tools by name via Spring AI's ToolCallbackResolver. This enabl [...] + "userMessage": { "index": 7, "kind": "parameter", "displayName": "User Message", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Default user message text for multimodal requests. Can be combined with media data in the message b [...] + "lazyStartProducer": { "index": 8, "kind": "parameter", "displayName": "Lazy Start Producer", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should be started lazy (on the first message). By starting lazy you can use this to allow CamelContext and routes to startup in situations where a produc [...] + "advisors": { "index": 9, "kind": "parameter", "displayName": "Advisors", "group": "advanced", "label": "advanced", "required": false, "type": "array", "javaType": "java.util.List<org.springframework.ai.chat.client.advisor.api.Advisor>", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "List of custom advisor [...] + "chatMemory": { "index": 10, "kind": "parameter", "displayName": "Chat Memory", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "org.springframework.ai.chat.memory.ChatMemory", "deprecated": false, "deprecationNote": "", "autowired": true, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "ChatMemory instance for maintaining conve [...] + "chatMemoryVectorStore": { "index": 11, "kind": "parameter", "displayName": "Chat Memory Vector Store", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "org.springframework.ai.vectorstore.VectorStore", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "VectorStore instance for maintaining c [...] + "entityClass": { "index": 12, "kind": "parameter", "displayName": "Entity Class", "group": "advanced", "label": "advanced", "required": false, "type": "string", "javaType": "java.lang.Class<java.lang.Object>", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "The Java class to use for entity response conversion using ChatClient.ent [...] + "maxFileSize": { "index": 13, "kind": "parameter", "displayName": "Max File Size", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 1048576, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Maximum file size in bytes for multimodal content (images, audio, PDFs, etc [...] + "maxTokens": { "index": 14, "kind": "parameter", "displayName": "Max Tokens", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "java.lang.Integer", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Maximum tokens in the response." }, + "mcpServer": { "index": 15, "kind": "parameter", "displayName": "Mcp Server", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "java.util.Map<java.lang.String, java.lang.Object>", "prefix": "mcpServer.", "multiValue": true, "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "MCP server config [...] + "mcpTimeout": { "index": 16, "kind": "parameter", "displayName": "Mcp Timeout", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 20, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Timeout in seconds for MCP operations including tool execution and initialization" }, + "outputClass": { "index": 17, "kind": "parameter", "displayName": "Output Class", "group": "advanced", "label": "advanced", "required": false, "type": "string", "javaType": "java.lang.Class<java.lang.Object>", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "The Java class to use for BEAN output format conversion. Required when ou [...] + "outputFormat": { "index": 18, "kind": "parameter", "displayName": "Output Format", "group": "advanced", "label": "advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "The output format for structured output conversion (BEAN, MAP, LIST). Used in conjunctio [...] + "structuredOutputConverter": { "index": 19, "kind": "parameter", "displayName": "Structured Output Converter", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "org.springframework.ai.converter.StructuredOutputConverter<java.lang.Object>", "deprecated": false, "deprecationNote": "", "autowired": true, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configurati [...] + "structuredOutputValidation": { "index": 20, "kind": "parameter", "displayName": "Structured Output Validation", "group": "advanced", "label": "advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Enable structured output validation with aut [...] + "structuredOutputValidationMaxAttempts": { "index": 21, "kind": "parameter", "displayName": "Structured Output Validation Max Attempts", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 3, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Maximum number of retry atte [...] + "systemMetadata": { "index": 22, "kind": "parameter", "displayName": "System Metadata", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "java.util.Map<java.lang.String, java.lang.Object>", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Metadata to attach to system messages. This metadat [...] + "temperature": { "index": 23, "kind": "parameter", "displayName": "Temperature", "group": "advanced", "label": "advanced", "required": false, "type": "number", "javaType": "java.lang.Double", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Temperature parameter for response randomness (0.0-2.0)." }, + "toolCallbacks": { "index": 24, "kind": "parameter", "displayName": "Tool Callbacks", "group": "advanced", "label": "advanced", "required": false, "type": "array", "javaType": "java.util.List<org.springframework.ai.tool.ToolCallback>", "deprecated": false, "deprecationNote": "", "autowired": true, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "List of ToolCallback inst [...] + "toolContext": { "index": 25, "kind": "parameter", "displayName": "Tool Context", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "java.util.Map<java.lang.String, java.lang.Object>", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Context map to pass to tools during execution. Tool metho [...] + "topKSampling": { "index": 26, "kind": "parameter", "displayName": "Top KSampling", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "java.lang.Integer", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Top K parameter for sampling." }, + "topP": { "index": 27, "kind": "parameter", "displayName": "Top P", "group": "advanced", "label": "advanced", "required": false, "type": "number", "javaType": "java.lang.Double", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Top P parameter for nucleus sampling." }, + "userMetadata": { "index": 28, "kind": "parameter", "displayName": "User Metadata", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "java.util.Map<java.lang.String, java.lang.Object>", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Metadata to attach to user messages. This metadata can [...] + "ragTemplate": { "index": 29, "kind": "parameter", "displayName": "Rag Template", "group": "rag", "label": "rag", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "Context:\\n\\{context}\\n\\nQuestion: \\{question}", "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Template for formatting RAG (R [...] + "similarityThreshold": { "index": 30, "kind": "parameter", "displayName": "Similarity Threshold", "group": "rag", "label": "rag", "required": false, "type": "number", "javaType": "double", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 0.7, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Similarity threshold for RAG retrieval (default: 0.7)." }, + "topK": { "index": 31, "kind": "parameter", "displayName": "Top K", "group": "rag", "label": "rag", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 5, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Number of top documents to retrieve for RAG (default: 5)." }, + "vectorStore": { "index": 32, "kind": "parameter", "displayName": "Vector Store", "group": "rag", "label": "rag", "required": false, "type": "object", "javaType": "org.springframework.ai.vectorstore.VectorStore", "deprecated": false, "deprecationNote": "", "autowired": true, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "VectorStore for automatic RAG retrieval." }, + "safeguardFailureResponse": { "index": 33, "kind": "parameter", "displayName": "Safeguard Failure Response", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Failure response message for SafeGuard advisor when sensitive c [...] + "safeguardSensitiveWords": { "index": 34, "kind": "parameter", "displayName": "Safeguard Sensitive Words", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Comma-separated list of sensitive words for SafeGuard advisor. Wh [...] + "safeguardOrder": { "index": 35, "kind": "parameter", "displayName": "Safeguard Order", "group": "security (advanced)", "label": "security,advanced", "required": false, "type": "integer", "javaType": "java.lang.Integer", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.springai.chat.SpringAiChatConfiguration", "configurationField": "configuration", "description": "Order of execution for SafeGuard advisor. Lower numbers execut [...] } } diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/docs/spring-ai-chat-component.adoc b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/docs/spring-ai-chat-component.adoc index 2b15fd4cc9ee..6b3054cc229e 100644 --- a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/docs/spring-ai-chat-component.adoc +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/docs/spring-ai-chat-component.adoc @@ -577,6 +577,233 @@ from("direct:chat") NOTE: Most users should use `ChatModel` and let the component handle `ChatClient` creation automatically. Only use this approach if you have specific requirements that cannot be met with standard configuration options. +=== Spring @Tool Bean Discovery + +In addition to Camel route tools (via `tags`), you can use Spring AI `@Tool`-annotated beans directly. + +==== Camel Spring Boot (Recommended) + +When using Camel Spring Boot, Spring AI's auto-configuration automatically discovers `@Tool`-annotated +methods from Spring beans via `ToolCallbackResolver`. Simply define your tool as a Spring bean and +reference it by name using `toolNames`: + +[source,java] +---- +@Component +public class MyTools { + + @Tool(description = "Get the capital city of a country", name = "getCapital") + public String getCapital(String country) { + return switch (country.toLowerCase()) { + case "france" -> "Paris"; + case "germany" -> "Berlin"; + case "italy" -> "Rome"; + default -> "Unknown"; + }; + } +} + +// In your route — no manual callback registration needed +from("direct:chat") + .to("spring-ai-chat:chat?chatModel=#chatModel&toolNames=getCapital"); +---- + +==== Without Spring Boot + +Outside Spring Boot (e.g., in plain Camel or tests), there is no `ToolCallbackResolver`. +You need to resolve the callbacks manually and bind them in the registry: + +[source,java] +---- +ToolCallbackProvider provider = MethodToolCallbackProvider.builder() + .toolObjects(new MyTools()) + .build(); +context.getRegistry().bind("myTools", Arrays.asList(provider.getToolCallbacks())); + +from("direct:chat") + .to("spring-ai-chat:chat?chatModel=#chatModel&toolCallbacks=#myTools"); +---- + +==== Tool Selection by Name + +Select specific tools by name using the `toolNames` parameter, instead of (or in addition to) tag-based discovery. +This is useful for selecting individual `@Tool` methods or controlling which tools are available per endpoint: + +[source,java] +---- +// Select only the getCapital tool by name +from("direct:chat") + .to("spring-ai-chat:chat?chatModel=#chatModel&toolNames=getCapital"); + +// Select tools dynamically at runtime via header +template.requestBodyAndHeader("direct:chat", "What is the capital of France?", + SpringAiChatConstants.TOOL_NAMES, "getCapital", String.class); +---- + +Tool names are resolved via Spring AI's `ToolCallbackResolver`. You can combine `toolNames` with `tags` — +both tool sources are additive. + +=== Tool Context + +Pass contextual data (user ID, session info, tenant ID, etc.) to tools during execution. +Tools with a `ToolContext` parameter receive these values automatically: + +[source,java] +---- +// Tool that uses context +@Tool(description = "Get user profile") +public String getUserProfile(@ToolParam(description = "detail level") String detail, + ToolContext toolContext) { + String userId = (String) toolContext.getContext().get("userId"); + return "Profile for user: " + userId; +} + +// Via endpoint configuration +from("direct:chat") + .to("spring-ai-chat:chat?chatModel=#chatModel&toolCallbacks=#myTools" + + "&toolContext.userId=user-42" + + "&toolContext.role=admin"); + +// Via header (overrides endpoint config) +template.request("direct:chat", e -> { + e.getIn().setBody("Get my profile"); + e.getIn().setHeader(SpringAiChatConstants.TOOL_CONTEXT, + Map.of("userId", "user-99", "role", "admin")); +}); +---- + +NOTE: Tool context works with Spring AI `@Tool` methods and MCP tools. +Camel route tools (defined via `spring-ai-tools` consumer) do not receive the `ToolContext`. + +=== Structured Output Validation + +Enable automatic validation of structured output with retry on failure. +When the LLM produces invalid output (doesn't match the expected JSON Schema), +the advisor re-prompts the LLM with validation errors: + +[source,java] +---- +from("direct:chat") + .to("spring-ai-chat:chat?chatModel=#chatModel" + + "&outputFormat=BEAN" + + "&outputClass=com.example.ActorFilms" + + "&structuredOutputValidation=true" + + "&structuredOutputValidationMaxAttempts=3"); +---- + +If all retry attempts fail, the exception propagates to Camel's error handler: + +[source,java] +---- +from("direct:chat") + .doTry() + .to("spring-ai-chat:chat?chatModel=#chatModel" + + "&outputFormat=BEAN&outputClass=com.example.ActorFilms" + + "&structuredOutputValidation=true&structuredOutputValidationMaxAttempts=2") + .doCatch(Exception.class) + .log("Structured output validation failed: ${exception.message}") + .to("direct:fallback") + .end(); +---- + +The advisor requires `outputClass` or `entityClass` to be set so it can generate a JSON Schema +for validation. A warning is logged if neither is configured. + +=== MCP Client (Model Context Protocol) + +The component supports connecting to MCP servers, enabling the LLM to use tools provided by +external MCP-compatible services. This follows the same pattern as the +xref:openai-component.adoc[OpenAI component]'s MCP support. + +Configure MCP servers using the `mcpServer.<name>.<property>` prefix notation: + +==== Stdio Transport + +[source,java] +---- +// Connect to MCP filesystem server via stdio +from("direct:chat") + .to("spring-ai-chat:chat?chatModel=#chatModel" + + "&mcpServer.fs.transportType=stdio" + + "&mcpServer.fs.command=npx" + + "&mcpServer.fs.args=-y,@modelcontextprotocol/server-filesystem,/tmp"); +---- + +==== SSE Transport + +[source,java] +---- +// Connect to MCP server via SSE +from("direct:chat") + .to("spring-ai-chat:chat?chatModel=#chatModel" + + "&mcpServer.weather.transportType=sse" + + "&mcpServer.weather.url=http://mcp-server:3001/sse"); +---- + +==== Multiple MCP Servers + +[source,java] +---- +from("direct:chat") + .to("spring-ai-chat:chat?chatModel=#chatModel" + + "&mcpServer.fs.transportType=stdio" + + "&mcpServer.fs.command=npx" + + "&mcpServer.fs.args=-y,@modelcontextprotocol/server-filesystem,/tmp" + + "&mcpServer.weather.transportType=sse" + + "&mcpServer.weather.url=http://weather-mcp:3001/sse"); +---- + +==== OAuth Authentication for MCP Servers + +For MCP servers requiring authentication, configure an OAuth profile per server. +This requires `camel-oauth` on the classpath: + +[source,java] +---- +from("direct:chat") + .to("spring-ai-chat:chat?chatModel=#chatModel" + + "&mcpServer.api.transportType=sse" + + "&mcpServer.api.url=http://secure-mcp:3001/sse" + + "&mcpServer.api.oauthProfile=keycloak"); +---- + +With corresponding properties: + +[source,properties] +---- +camel.oauth.keycloak.client-id=my-client +camel.oauth.keycloak.client-secret=my-secret +camel.oauth.keycloak.token-endpoint=https://keycloak/realms/myrealm/protocol/openid-connect/token +---- + +IMPORTANT: The Spring AI chat component uses the MCP SDK version managed by the Spring AI BOM. +Using this component's MCP support alongside `camel-openai` MCP support in the same application +may cause version conflicts. Choose one approach per application. + +=== Observability + +When using Camel Spring Boot with `spring-boot-starter-actuator`, Spring AI's built-in +observability support is automatically enabled. No additional Camel-side configuration is needed — +the `ChatModel` and `ChatClient` calls are instrumented by Spring AI through Micrometer. + +Add the actuator dependency to your Spring Boot application: + +[source,xml] +---- +<dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-actuator</artifactId> +</dependency> +---- + +This works automatically because the Camel component delegates to Spring AI's +`ChatClient` and `ChatModel`, which are already instrumented. The component itself does not need +to add any observation code — Spring AI's auto-configuration handles everything when actuator +is on the classpath. + +Refer to the https://docs.spring.io/spring-ai/reference/observability/index.html[Spring AI Observability documentation] +for details on available metrics, tracing, and prompt/completion logging configuration. + === Request/Response Logging The component automatically adds Spring AI's `SimpleLoggerAdvisor` to log requests and responses for debugging. Enable it by setting the logger `org.springframework.ai.chat.client.advisor` to DEBUG level: @@ -593,5 +820,6 @@ logging.level.org.springframework.ai.chat.client.advisor=DEBUG * xref:spring-ai-tools-component.adoc[Spring AI Tools Component] * xref:spring-ai-embeddings-component.adoc[Spring AI Embeddings Component] * xref:spring-ai-vector-store-component.adoc[Spring AI VectorStore Component] +* xref:spring-ai-image-component.adoc[Spring AI Image Component] include::spring-boot:partial$starter.adoc[] diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatConfiguration.java b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatConfiguration.java index a9dd2341bc14..52e4324c8542 100644 --- a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatConfiguration.java +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatConfiguration.java @@ -27,6 +27,7 @@ import org.springframework.ai.chat.client.advisor.api.Advisor; import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.converter.StructuredOutputConverter; +import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.vectorstore.VectorStore; @UriParams @@ -120,6 +121,43 @@ public class SpringAiChatConfiguration implements Cloneable { @UriParam(label = "advanced", defaultValue = "1048576") private long maxFileSize = 1024 * 1024; // 1MB default + @UriParam(description = "Comma-separated tool names for selecting tools by name via Spring AI's ToolCallbackResolver. " + + "This enables selecting Spring @Tool annotated beans or any registered ToolCallback by name.") + private String toolNames; + + @UriParam(label = "advanced", + description = "List of ToolCallback instances to register alongside Camel route tools. " + + "Use MethodToolCallbackProvider.builder().toolObjects(bean).build().getToolCallbacks() " + + "to resolve callbacks from @Tool-annotated beans.") + @Metadata(autowired = true) + private List<ToolCallback> toolCallbacks; + + @UriParam(label = "advanced", + description = "Context map to pass to tools during execution. Tool methods accepting a ToolContext parameter will receive these values.") + private Map<String, Object> toolContext; + + @UriParam(label = "advanced", defaultValue = "false", + description = "Enable structured output validation with automatic retry on validation failure. " + + "When enabled, the StructuredOutputValidationAdvisor validates the response against a JSON Schema " + + "and re-prompts the LLM with validation errors if the output is invalid.") + private boolean structuredOutputValidation; + + @UriParam(label = "advanced", defaultValue = "3", + description = "Maximum number of retry attempts for structured output validation") + private int structuredOutputValidationMaxAttempts = 3; + + @UriParam(prefix = "mcpServer.", multiValue = true, label = "advanced", + description = "MCP server configurations. Define servers using prefix notation: " + + "mcpServer.<name>.transportType=stdio|sse, " + + "mcpServer.<name>.command=<cmd> (stdio), mcpServer.<name>.args=<comma-separated> (stdio), " + + "mcpServer.<name>.url=<url> (sse), " + + "mcpServer.<name>.oauthProfile=<profile> (OAuth profile for HTTP auth, requires camel-oauth)") + private Map<String, Object> mcpServer; + + @UriParam(label = "advanced", defaultValue = "20", + description = "Timeout in seconds for MCP operations including tool execution and initialization") + private int mcpTimeout = 20; + public ChatClient getChatClient() { return chatClient; } @@ -436,6 +474,96 @@ public class SpringAiChatConfiguration implements Cloneable { this.maxFileSize = maxFileSize; } + public String getToolNames() { + return toolNames; + } + + /** + * Comma-separated tool names for selecting tools by name via Spring AI's ToolCallbackResolver. This enables + * selecting Spring @Tool annotated beans or any registered ToolCallback by name, in addition to tag-based + * discovery. + */ + public void setToolNames(String toolNames) { + this.toolNames = toolNames; + } + + public List<ToolCallback> getToolCallbacks() { + return toolCallbacks; + } + + /** + * List of additional ToolCallback instances to register alongside Camel route tools. These callbacks are added + * directly without requiring a ToolCallbackProvider. + */ + public void setToolCallbacks(List<ToolCallback> toolCallbacks) { + this.toolCallbacks = toolCallbacks; + } + + public Map<String, Object> getToolContext() { + return toolContext; + } + + /** + * Context map to pass to tools during execution. Tool methods accepting a ToolContext parameter will receive these + * values. Can be overridden per-request via the CamelSpringAiChatToolContext header. + */ + public void setToolContext(Map<String, Object> toolContext) { + this.toolContext = toolContext; + } + + public Map<String, Object> getMcpServer() { + return mcpServer; + } + + /** + * MCP server configurations for Model Context Protocol integration. Define servers using prefix notation: + * mcpServer.<name>.transportType=stdio|sse, mcpServer.<name>.command=<cmd> (stdio), + * mcpServer.<name>.args=<comma-separated> (stdio), mcpServer.<name>.url=<url> (sse). + * <p> + * <strong>Note:</strong> Using this component with MCP alongside the camel-openai component with MCP may lead to + * MCP SDK version conflicts. The spring-ai-chat component uses MCP SDK version managed by Spring AI BOM while + * camel-openai uses the version defined in camel-parent. Avoid using both in the same application. + * </p> + */ + public void setMcpServer(Map<String, Object> mcpServer) { + this.mcpServer = mcpServer; + } + + public int getMcpTimeout() { + return mcpTimeout; + } + + /** + * Timeout in seconds for MCP operations including tool execution and initialization. Default is 20. + */ + public void setMcpTimeout(int mcpTimeout) { + this.mcpTimeout = mcpTimeout; + } + + public boolean isStructuredOutputValidation() { + return structuredOutputValidation; + } + + /** + * Enable structured output validation with automatic retry on validation failure. When enabled, the + * StructuredOutputValidationAdvisor validates the response against a JSON Schema derived from the outputClass or + * entityClass, and re-prompts the LLM with validation errors if the output is invalid. + */ + public void setStructuredOutputValidation(boolean structuredOutputValidation) { + this.structuredOutputValidation = structuredOutputValidation; + } + + public int getStructuredOutputValidationMaxAttempts() { + return structuredOutputValidationMaxAttempts; + } + + /** + * Maximum number of retry attempts for structured output validation. Default is 3. + */ + public void setStructuredOutputValidationMaxAttempts(int structuredOutputValidationMaxAttempts) { + this.structuredOutputValidationMaxAttempts = structuredOutputValidationMaxAttempts; + } + public SpringAiChatConfiguration copy() { try { return (SpringAiChatConfiguration) clone(); diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatConstants.java b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatConstants.java index dd43ea80fa17..bd7090974bc6 100644 --- a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatConstants.java +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatConstants.java @@ -119,6 +119,14 @@ public final class SpringAiChatConstants { javaType = "java.util.Map<String, Object>") public static final String RESPONSE_METADATA = "CamelSpringAiChatResponseMetadata"; + @Metadata(description = "Comma-separated tool names for selecting tools by name via ToolCallbackResolver", + javaType = "String") + public static final String TOOL_NAMES = "CamelSpringAiChatToolNames"; + + @Metadata(description = "Context map to pass to tools during execution", + javaType = "java.util.Map<String, Object>") + public static final String TOOL_CONTEXT = "CamelSpringAiChatToolContext"; + private SpringAiChatConstants() { } } diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatProducer.java b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatProducer.java index e39a23392827..322f1cd427c7 100644 --- a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatProducer.java +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatProducer.java @@ -21,6 +21,7 @@ import java.io.IOException; import java.nio.file.Files; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -30,6 +31,7 @@ import org.apache.camel.Exchange; import org.apache.camel.InvalidPayloadException; import org.apache.camel.NoSuchHeaderException; import org.apache.camel.WrappedFile; +import org.apache.camel.component.springai.chat.mcp.SpringAiChatMcpManager; import org.apache.camel.component.springai.tools.TagsHelper; import org.apache.camel.component.springai.tools.spec.CamelToolExecutorCache; import org.apache.camel.component.springai.tools.spec.CamelToolSpecification; @@ -40,6 +42,7 @@ import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; import org.springframework.ai.chat.client.advisor.SafeGuardAdvisor; import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; +import org.springframework.ai.chat.client.advisor.StructuredOutputValidationAdvisor; import org.springframework.ai.chat.client.advisor.api.Advisor; import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor; import org.springframework.ai.chat.client.advisor.vectorstore.VectorStoreChatMemoryAdvisor; @@ -72,6 +75,7 @@ public class SpringAiChatProducer extends DefaultProducer { private static final Logger LOG = LoggerFactory.getLogger(SpringAiChatProducer.class); private ChatClient chatClient; + private SpringAiChatMcpManager mcpManager; public SpringAiChatProducer(SpringAiChatEndpoint endpoint) { super(endpoint); @@ -115,6 +119,23 @@ public class SpringAiChatProducer extends DefaultProducer { this.chatClient = builder.build(); } + + // Initialize MCP clients if configured + Map<String, Object> mcpConfig = getEndpoint().getConfiguration().getMcpServer(); + if (mcpConfig != null && !mcpConfig.isEmpty()) { + mcpManager = new SpringAiChatMcpManager(); + mcpManager.initialize(mcpConfig, getEndpoint().getConfiguration().getMcpTimeout(), + getEndpoint().getCamelContext()); + } + } + + @Override + protected void doStop() throws Exception { + if (mcpManager != null) { + mcpManager.close(); + mcpManager = null; + } + super.doStop(); } @Override @@ -553,6 +574,40 @@ public class SpringAiChatProducer extends DefaultProducer { ToolCallingChatOptions options = optionsBuilder.build(); request.options(options); + // Add direct ToolCallbacks if configured + List<ToolCallback> configuredCallbacks = getEndpoint().getConfiguration().getToolCallbacks(); + if (configuredCallbacks != null && !configuredCallbacks.isEmpty()) { + request.toolCallbacks(configuredCallbacks); + LOG.debug("Added {} configured ToolCallbacks", configuredCallbacks.size()); + } + + // Add MCP tool callbacks if MCP servers are configured + if (mcpManager != null && mcpManager.getToolCallbackProvider() != null) { + request.toolCallbacks(mcpManager.getToolCallbackProvider()); + LOG.debug("Added MCP tool callback provider"); + } + + // Apply tool names for resolution via ToolCallbackResolver + String toolNames = exchange.getIn().getHeader(SpringAiChatConstants.TOOL_NAMES, String.class); + if (toolNames == null) { + toolNames = getEndpoint().getConfiguration().getToolNames(); + } + if (toolNames != null && !toolNames.trim().isEmpty()) { + String[] names = Arrays.stream(toolNames.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toArray(String[]::new); + request.toolNames(names); + LOG.debug("Added {} tool names for resolution: {}", names.length, Arrays.toString(names)); + } + + // Apply tool context if configured + Map<String, Object> toolContext = getToolContext(exchange); + if (toolContext != null && !toolContext.isEmpty()) { + request.toolContext(toolContext); + LOG.debug("Added tool context with {} entries", toolContext.size()); + } + // Apply conversation ID for chat memory if provided String conversationId = exchange.getIn().getHeader(SpringAiChatConstants.CONVERSATION_ID, String.class); if (conversationId != null) { @@ -719,6 +774,20 @@ public class SpringAiChatProducer extends DefaultProducer { return getEndpoint().getConfiguration().getSystemMetadata(); } + @SuppressWarnings("unchecked") + private Map<String, Object> getToolContext(Exchange exchange) { + Map<String, Object> headerContext = exchange.getIn().getHeader(SpringAiChatConstants.TOOL_CONTEXT, Map.class); + Map<String, Object> configContext = getEndpoint().getConfiguration().getToolContext(); + + if (headerContext != null && configContext != null) { + // Merge: header overrides config + Map<String, Object> merged = new HashMap<>(configContext); + merged.putAll(headerContext); + return merged; + } + return headerContext != null ? headerContext : configContext; + } + private Class<?> getEntityClass(Exchange exchange) { // Check if entity class is provided via header (takes highest priority) Class<?> entityClass = exchange.getIn().getHeader(SpringAiChatConstants.ENTITY_CLASS, Class.class); @@ -1142,6 +1211,26 @@ public class SpringAiChatProducer extends DefaultProducer { getEndpoint().getConfiguration().getSimilarityThreshold()); } + // Add StructuredOutputValidationAdvisor if configured + if (getEndpoint().getConfiguration().isStructuredOutputValidation()) { + Class<?> outputType = getEndpoint().getConfiguration().getOutputClass(); + if (outputType == null) { + outputType = getEndpoint().getConfiguration().getEntityClass(); + } + if (outputType != null) { + advisors.add(StructuredOutputValidationAdvisor.builder() + .outputType(outputType) + .maxRepeatAttempts(getEndpoint().getConfiguration().getStructuredOutputValidationMaxAttempts()) + .build()); + LOG.debug("StructuredOutputValidationAdvisor enabled with maxRepeatAttempts={} for type={}", + getEndpoint().getConfiguration().getStructuredOutputValidationMaxAttempts(), + outputType.getName()); + } else { + LOG.warn("structuredOutputValidation is enabled but no outputClass or entityClass is configured. " + + "The advisor requires a type to validate against."); + } + } + // Add custom advisors if configured List<Advisor> customAdvisors = getEndpoint().getConfiguration().getAdvisors(); if (customAdvisors != null && !customAdvisors.isEmpty()) { diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/mcp/SpringAiChatMcpManager.java b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/mcp/SpringAiChatMcpManager.java new file mode 100644 index 000000000000..d778d541478f --- /dev/null +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/mcp/SpringAiChatMcpManager.java @@ -0,0 +1,186 @@ +/* + * 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.component.springai.chat.mcp; + +import java.net.http.HttpRequest; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; +import io.modelcontextprotocol.client.transport.ServerParameters; +import io.modelcontextprotocol.client.transport.StdioClientTransport; +import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper; +import io.modelcontextprotocol.spec.McpClientTransport; +import org.apache.camel.CamelContext; +import org.apache.camel.support.OAuthHelper; +import org.apache.camel.util.ObjectHelper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.mcp.SyncMcpToolCallbackProvider; +import org.springframework.ai.tool.ToolCallbackProvider; + +/** + * Manages MCP (Model Context Protocol) client lifecycle for the Spring AI Chat component. + * <p> + * Handles initialization, tool discovery via {@link SyncMcpToolCallbackProvider}, and graceful shutdown of MCP clients. + * Supports stdio, sse, and sse transport types. + * </p> + */ +public class SpringAiChatMcpManager { + + private static final Logger LOG = LoggerFactory.getLogger(SpringAiChatMcpManager.class); + + private final List<McpSyncClient> mcpClients = new ArrayList<>(); + private SyncMcpToolCallbackProvider toolCallbackProvider; + + /** + * Initialize MCP clients from flat configuration map. + * <p> + * Configuration keys follow the pattern: {@code <serverName>.<property>}. Supported properties: + * <ul> + * <li>{@code transportType} - Required. One of: stdio, sse</li> + * <li>{@code command} - Required for stdio. The command to execute</li> + * <li>{@code args} - Optional for stdio. Comma-separated arguments</li> + * <li>{@code url} - Required for sse. The server URL</li> + * <li>{@code oauthProfile} - Optional for sse. OAuth profile name for obtaining a Bearer token (requires + * camel-oauth)</li> + * </ul> + * + * @param mcpServerConfig flat configuration map with dotted keys + * @param mcpTimeout timeout in seconds for MCP operations + * @param camelContext the CamelContext for OAuth token resolution + * @throws Exception if initialization fails + */ + public void initialize(Map<String, Object> mcpServerConfig, int mcpTimeout, CamelContext camelContext) + throws Exception { + // Group flat keys by server name: "fs.transportType" -> {"fs": {"transportType": ...}} + Map<String, Map<String, String>> serverConfigs = new HashMap<>(); + for (Map.Entry<String, Object> entry : mcpServerConfig.entrySet()) { + String key = entry.getKey(); + int dot = key.indexOf('.'); + if (dot < 0) { + continue; + } + String serverName = key.substring(0, dot); + String property = key.substring(dot + 1); + serverConfigs.computeIfAbsent(serverName, k -> new HashMap<>()).put(property, String.valueOf(entry.getValue())); + } + + Duration timeout = Duration.ofSeconds(mcpTimeout); + + for (Map.Entry<String, Map<String, String>> entry : serverConfigs.entrySet()) { + String serverName = entry.getKey(); + Map<String, String> props = entry.getValue(); + + String transportType = props.get("transportType"); + if (transportType == null) { + throw new IllegalArgumentException("mcpServer." + serverName + ".transportType is required"); + } + + LOG.debug("Creating MCP transport for server '{}' with type '{}'", serverName, transportType); + McpClientTransport transport = createTransport(serverName, transportType, props, camelContext); + McpSyncClient mcpClient = McpClient.sync(transport) + .requestTimeout(timeout) + .initializationTimeout(timeout) + .build(); + mcpClient.initialize(); + mcpClients.add(mcpClient); + LOG.info("Initialized MCP server '{}'", serverName); + } + + if (!mcpClients.isEmpty()) { + toolCallbackProvider = SyncMcpToolCallbackProvider.builder() + .mcpClients(mcpClients) + .build(); + LOG.info("MCP tool callback provider created with {} tool callbacks from {} servers", + toolCallbackProvider.getToolCallbacks().length, mcpClients.size()); + } + } + + /** + * Returns the tool callback provider that provides MCP tools as Spring AI ToolCallbacks. + */ + public ToolCallbackProvider getToolCallbackProvider() { + return toolCallbackProvider; + } + + /** + * Close all MCP clients gracefully. + */ + public void close() { + for (McpSyncClient client : mcpClients) { + try { + client.close(); + } catch (Exception e) { + LOG.debug("Error closing MCP client: {}", e.getMessage()); + } + } + mcpClients.clear(); + toolCallbackProvider = null; + LOG.debug("All MCP clients closed"); + } + + private McpClientTransport createTransport( + String serverName, String transportType, Map<String, String> props, CamelContext camelContext) + throws Exception { + + // Resolve per-server OAuth token if configured + String oauthProfile = props.get("oauthProfile"); + HttpRequest.Builder authRequestBuilder = null; + if (ObjectHelper.isNotEmpty(oauthProfile)) { + String token = OAuthHelper.resolveOAuthToken(camelContext, oauthProfile); + authRequestBuilder = HttpRequest.newBuilder() + .header("Authorization", "Bearer " + token); + LOG.debug("OAuth token resolved for MCP server '{}' using profile '{}'", serverName, oauthProfile); + } + + return switch (transportType) { + case "stdio" -> { + String command = props.get("command"); + if (command == null) { + throw new IllegalArgumentException("mcpServer." + serverName + ".command is required for stdio transport"); + } + ServerParameters.Builder paramsBuilder = ServerParameters.builder(command); + String args = props.get("args"); + if (args != null) { + paramsBuilder.args(List.of(args.split(","))); + } + yield new StdioClientTransport(paramsBuilder.build(), new JacksonMcpJsonMapper(new ObjectMapper())); + } + case "sse" -> { + String url = props.get("url"); + if (url == null) { + throw new IllegalArgumentException("mcpServer." + serverName + ".url is required for sse transport"); + } + HttpClientSseClientTransport.Builder sseBuilder = HttpClientSseClientTransport.builder(url); + if (authRequestBuilder != null) { + sseBuilder.requestBuilder(authRequestBuilder); + } + yield sseBuilder.build(); + } + default -> throw new IllegalArgumentException( + "Unknown transport type '" + transportType + "' for mcpServer." + serverName + + ". Supported: stdio, sse"); + }; + } +} diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatMcpIT.java b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatMcpIT.java new file mode 100644 index 000000000000..6d943678c5fe --- /dev/null +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatMcpIT.java @@ -0,0 +1,88 @@ +/* + * 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.component.springai.chat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.apache.camel.builder.RouteBuilder; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfSystemProperty; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test for MCP (Model Context Protocol) client support. + * + * Uses the MCP filesystem server via stdio transport. Requires Node.js and npx to be available on the system path. + * + * The test creates temporary files and verifies that the LLM can use MCP tools to read and list them. + */ +@DisabledIfSystemProperty(named = "ci.env.name", matches = ".*", disabledReason = "Disabled unless running in CI") +public class SpringAiChatMcpIT extends OllamaTestSupport { + + @TempDir + static Path tempDir; + + @BeforeAll + static void setupTestFiles() throws IOException { + // Create test files for the MCP filesystem server to read + Files.writeString(tempDir.resolve("hello.txt"), "Hello from Camel Spring AI MCP test!"); + Files.writeString(tempDir.resolve("data.csv"), "name,age\nAlice,30\nBob,25"); + } + + @Test + public void testMcpListFiles() { + String response = template().requestBody("direct:mcpChat", + "List the files in the allowed directory", String.class); + + assertThat(response).isNotNull(); + assertThat(response.toLowerCase()).containsAnyOf("hello", "data", "txt", "csv", "file"); + } + + @Test + public void testMcpReadFile() { + String response = template().requestBody("direct:mcpChat", + "Read the contents of hello.txt", String.class); + + assertThat(response).isNotNull(); + assertThat(response.toLowerCase()).containsAnyOf("hello", "camel", "mcp"); + } + + @Override + protected RouteBuilder createRouteBuilder() throws Exception { + String tempDirPath = tempDir.toAbsolutePath().toString(); + + return new RouteBuilder() { + @Override + public void configure() throws Exception { + bindChatModel(getCamelContext()); + + // Chat endpoint with MCP filesystem server via stdio transport + from("direct:mcpChat") + .toF("spring-ai-chat:mcpChat?chatModel=#chatModel" + + "&mcpServer.fs.transportType=stdio" + + "&mcpServer.fs.command=npx" + + "&mcpServer.fs.args=-y,@modelcontextprotocol/server-filesystem,%s", + tempDirPath); + } + }; + } +} diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatMcpSseIT.java b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatMcpSseIT.java new file mode 100644 index 000000000000..decd9ff56efd --- /dev/null +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatMcpSseIT.java @@ -0,0 +1,74 @@ +/* + * 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.component.springai.chat; + +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.infra.mcp.everything.services.McpEverythingSseService; +import org.apache.camel.test.infra.mcp.everything.services.McpEverythingSseServiceFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfSystemProperty; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test for MCP client support using SSE transport with the MCP Everything Server. + * + * Uses the tzolov/mcp-everything-server:v3 Docker container in SSE mode, which provides echo and add tools. + */ +@DisabledIfSystemProperty(named = "ci.env.name", matches = ".*", disabledReason = "Disabled unless running in CI") +public class SpringAiChatMcpSseIT extends OllamaTestSupport { + + @RegisterExtension + static McpEverythingSseService MCP_EVERYTHING = McpEverythingSseServiceFactory.createService(); + + @Test + public void testMcpEchoTool() { + String response = template().requestBody("direct:mcpSseChat", + "Use the echo tool to echo the message 'Hello from Camel'.", String.class); + + assertThat(response).isNotNull(); + assertThat(response.toLowerCase()).containsAnyOf("hello", "camel"); + } + + @Test + public void testMcpAddTool() { + String response = template().requestBody("direct:mcpSseChat", + "Use the add tool to add 17 and 25.", String.class); + + assertThat(response).isNotNull(); + assertThat(response).containsAnyOf("42", "forty-two", "forty two"); + } + + @Override + protected RouteBuilder createRouteBuilder() throws Exception { + String sseUrl = MCP_EVERYTHING.sseUrl(); + + return new RouteBuilder() { + @Override + public void configure() throws Exception { + bindChatModel(getCamelContext()); + + from("direct:mcpSseChat") + .toF("spring-ai-chat:mcpSseChat?chatModel=#chatModel" + + "&mcpServer.everything.transportType=sse" + + "&mcpServer.everything.url=%s", + sseUrl); + } + }; + } +} diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatStructuredOutputValidationIT.java b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatStructuredOutputValidationIT.java new file mode 100644 index 000000000000..13d537c69a99 --- /dev/null +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatStructuredOutputValidationIT.java @@ -0,0 +1,129 @@ +/* + * 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.component.springai.chat; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import org.apache.camel.builder.RouteBuilder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfSystemProperty; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test for StructuredOutputValidationAdvisor. + * + * Tests that the advisor validates structured output against JSON Schema and retries on validation failure. If all + * retries fail, the exception propagates to Camel's error handler. + */ +@DisabledIfSystemProperty(named = "ci.env.name", matches = ".*", disabledReason = "Disabled unless running in CI") +public class SpringAiChatStructuredOutputValidationIT extends OllamaTestSupport { + + @Test + public void testValidationWithBeanOutput() { + var exchange = template().request("direct:validatedChat", e -> { + e.getIn().setBody("Generate filmography for Tom Hanks. Include at least 3 movies."); + }); + + assertThat(exchange).isNotNull(); + assertThat(exchange.getException()).isNull(); + + Object body = exchange.getMessage().getBody(); + assertThat(body).isInstanceOf(ActorFilms.class); + + ActorFilms actorFilms = (ActorFilms) body; + assertThat(actorFilms.actor()).isNotNull(); + assertThat(actorFilms.movies()).isNotNull().isNotEmpty(); + } + + @Test + public void testValidationWithEntityClass() { + var exchange = template().request("direct:entityValidatedChat", e -> { + e.getIn().setBody("Generate filmography for Meryl Streep. Include at least 3 movies."); + }); + + assertThat(exchange).isNotNull(); + assertThat(exchange.getException()).isNull(); + + Object body = exchange.getMessage().getBody(); + assertThat(body).isInstanceOf(ActorFilms.class); + + ActorFilms actorFilms = (ActorFilms) body; + assertThat(actorFilms.actor()).isNotNull(); + assertThat(actorFilms.movies()).isNotNull().isNotEmpty(); + } + + @Test + public void testValidationFailurePropagesToErrorHandler() { + // Use an unreasonable request that may produce invalid output + // The error handler should catch the exception after max retries + var exchange = template().request("direct:errorHandledChat", e -> { + e.getIn().setBody("Generate filmography for Tom Hanks"); + }); + + assertThat(exchange).isNotNull(); + // Either success or error handled — exchange should complete + if (exchange.getException() == null) { + // Validation succeeded — body should be ActorFilms + assertThat(exchange.getMessage().getBody()).isInstanceOf(ActorFilms.class); + } + // If exception, it was propagated correctly to Camel error handling + } + + @Override + protected RouteBuilder createRouteBuilder() throws Exception { + return new RouteBuilder() { + @Override + public void configure() throws Exception { + bindChatModel(getCamelContext()); + + // Chat with structured output validation using outputFormat/outputClass + from("direct:validatedChat") + .to("spring-ai-chat:validatedChat?chatModel=#chatModel" + + "&outputFormat=BEAN" + + "&outputClass=org.apache.camel.component.springai.chat.SpringAiChatStructuredOutputValidationIT$ActorFilms" + + "&structuredOutputValidation=true" + + "&structuredOutputValidationMaxAttempts=3"); + + // Chat with structured output validation using entityClass + from("direct:entityValidatedChat") + .to("spring-ai-chat:entityValidatedChat?chatModel=#chatModel" + + "&entityClass=org.apache.camel.component.springai.chat.SpringAiChatStructuredOutputValidationIT$ActorFilms" + + "&structuredOutputValidation=true" + + "&structuredOutputValidationMaxAttempts=3"); + + // Chat with error handling + from("direct:errorHandledChat") + .doTry() + .to("spring-ai-chat:errorHandledChat?chatModel=#chatModel" + + "&outputFormat=BEAN" + + "&outputClass=org.apache.camel.component.springai.chat.SpringAiChatStructuredOutputValidationIT$ActorFilms" + + "&structuredOutputValidation=true" + + "&structuredOutputValidationMaxAttempts=1") + .doCatch(Exception.class) + .log("Validation failed after retries: ${exception.message}") + .setBody(simple("Validation failed: ${exception.message}")) + .end(); + } + }; + } + + @JsonPropertyOrder({ "actor", "movies" }) + public record ActorFilms(String actor, List<String> movies) { + } +} diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatToolBeanDiscoveryIT.java b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatToolBeanDiscoveryIT.java new file mode 100644 index 000000000000..24eeb350f180 --- /dev/null +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatToolBeanDiscoveryIT.java @@ -0,0 +1,119 @@ +/* + * 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.component.springai.chat; + +import java.util.Arrays; + +import org.apache.camel.builder.RouteBuilder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfSystemProperty; +import org.springframework.ai.tool.ToolCallbackProvider; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.method.MethodToolCallbackProvider; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test for @Tool bean discovery and toolNames-based tool selection. + * + * Tests that Spring @Tool annotated beans can be discovered via ToolCallbackProvider and selected by name using the + * toolNames parameter. + */ +@DisabledIfSystemProperty(named = "ci.env.name", matches = ".*", disabledReason = "Disabled unless running in CI") +public class SpringAiChatToolBeanDiscoveryIT extends OllamaTestSupport { + + @Test + public void testToolCallbackProviderDiscovery() { + String response = template().requestBody("direct:providerChat", + "What is the current date and time?", String.class); + + assertThat(response).isNotNull(); + // The LLM should use the tool and include date/time info + assertThat(response).isNotEmpty(); + } + + @Test + public void testToolSelectionByName() { + String response = template().requestBody("direct:namedChat", + "What is the capital of France?", String.class); + + assertThat(response).isNotNull(); + assertThat(response.toLowerCase()).containsAnyOf("paris", "capital", "france"); + } + + @Test + public void testToolNamesViaHeader() { + var exchange = template().request("direct:headerChat", e -> { + e.getIn().setBody("What is the capital of Germany?"); + e.getIn().setHeader(SpringAiChatConstants.TOOL_NAMES, "getCapital"); + }); + + assertThat(exchange).isNotNull(); + String response = exchange.getMessage().getBody(String.class); + assertThat(response).isNotNull(); + assertThat(response.toLowerCase()).containsAnyOf("berlin", "capital", "germany"); + } + + @Override + protected RouteBuilder createRouteBuilder() throws Exception { + return new RouteBuilder() { + @Override + public void configure() throws Exception { + bindChatModel(getCamelContext()); + + // Resolve ToolCallbacks from @Tool-annotated bean + ToolCallbackProvider provider = MethodToolCallbackProvider.builder() + .toolObjects(new MyTools()) + .build(); + getCamelContext().getRegistry().bind("myTools", Arrays.asList(provider.getToolCallbacks())); + + // Chat with ToolCallbacks resolved from @Tool bean + from("direct:providerChat") + .to("spring-ai-chat:providerChat?chatModel=#chatModel&toolCallbacks=#myTools"); + + // Chat with toolNames selection + from("direct:namedChat") + .to("spring-ai-chat:namedChat?chatModel=#chatModel&toolCallbacks=#myTools&toolNames=getCapital"); + + // Chat without toolNames - selected via header at runtime + from("direct:headerChat") + .to("spring-ai-chat:headerChat?chatModel=#chatModel&toolCallbacks=#myTools"); + } + }; + } + + /** + * Spring AI @Tool annotated bean for testing. + */ + public static class MyTools { + + @Tool(description = "Get the current date and time") + public String getCurrentDateTime() { + return "The current date and time is 2026-03-11T10:30:00Z"; + } + + @Tool(description = "Get the capital city of a country") + public String getCapital(String country) { + return switch (country.toLowerCase()) { + case "france" -> "The capital of France is Paris"; + case "germany" -> "The capital of Germany is Berlin"; + case "italy" -> "The capital of Italy is Rome"; + default -> "Capital not found for " + country; + }; + } + } +} diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatToolContextIT.java b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatToolContextIT.java new file mode 100644 index 000000000000..fbfffefcc834 --- /dev/null +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatToolContextIT.java @@ -0,0 +1,105 @@ +/* + * 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.component.springai.chat; + +import java.util.Arrays; +import java.util.Map; + +import org.apache.camel.builder.RouteBuilder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfSystemProperty; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.ai.tool.method.MethodToolCallbackProvider; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test for ToolContext support. + * + * Tests that contextual data (e.g., user ID, session info) is passed to @Tool methods that accept a ToolContext + * parameter. + */ +@DisabledIfSystemProperty(named = "ci.env.name", matches = ".*", disabledReason = "Disabled unless running in CI") +public class SpringAiChatToolContextIT extends OllamaTestSupport { + + @Test + public void testToolContextFromConfig() { + // The route has toolContext configured with userId=user-42 + String response = template().requestBody("direct:contextChat", + "Get my user profile", String.class); + + assertThat(response).isNotNull(); + // The tool should receive the context and include user-42 in its response + assertThat(response.toLowerCase()).containsAnyOf("user-42", "user", "profile"); + } + + @Test + public void testToolContextFromHeader() { + var exchange = template().request("direct:headerContextChat", e -> { + e.getIn().setBody("Get my user profile"); + e.getIn().setHeader(SpringAiChatConstants.TOOL_CONTEXT, Map.of("userId", "user-99", "role", "admin")); + }); + + assertThat(exchange).isNotNull(); + String response = exchange.getMessage().getBody(String.class); + assertThat(response).isNotNull(); + assertThat(response.toLowerCase()).containsAnyOf("user-99", "admin", "user", "profile"); + } + + @Override + protected RouteBuilder createRouteBuilder() throws Exception { + return new RouteBuilder() { + @Override + public void configure() throws Exception { + bindChatModel(getCamelContext()); + + // Resolve ToolCallbacks from @Tool-annotated bean + var provider = MethodToolCallbackProvider.builder() + .toolObjects(new ContextAwareTools()) + .build(); + getCamelContext().getRegistry().bind("contextTools", + Arrays.asList(provider.getToolCallbacks())); + + // Chat with tool context configured on endpoint + from("direct:contextChat") + .to("spring-ai-chat:contextChat?chatModel=#chatModel" + + "&toolCallbacks=#contextTools" + + "&toolContext.userId=user-42" + + "&toolContext.role=viewer"); + + // Chat with tool context from header (no endpoint config) + from("direct:headerContextChat") + .to("spring-ai-chat:headerContextChat?chatModel=#chatModel" + + "&toolCallbacks=#contextTools"); + } + }; + } + + public static class ContextAwareTools { + + @Tool(description = "Get user profile information for the current user") + public String getUserProfile( + @ToolParam(description = "optional detail level") String detail, + ToolContext toolContext) { + String userId = (String) toolContext.getContext().getOrDefault("userId", "unknown"); + String role = (String) toolContext.getContext().getOrDefault("role", "guest"); + return String.format("User profile: userId=%s, role=%s", userId, role); + } + } +} diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/main/docs/spring-ai-image-component.adoc b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/main/docs/spring-ai-image-component.adoc index 13d151476efc..f371c09e15a5 100644 --- a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/main/docs/spring-ai-image-component.adoc +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/main/docs/spring-ai-image-component.adoc @@ -2,7 +2,7 @@ :doctitle: Spring AI Image :shortname: spring-ai-image :artifactid: camel-spring-ai-image -:description: Generate images using Spring AI. +:description: Spring AI Image Generation :since: 4.19 :supportlevel: Preview :tabs-sync-option: diff --git a/test-infra/camel-test-infra-mcp-everything/src/main/java/org/apache/camel/test/infra/mcp/everything/services/McpEverythingSseService.java b/test-infra/camel-test-infra-mcp-everything/src/main/java/org/apache/camel/test/infra/mcp/everything/services/McpEverythingSseService.java new file mode 100644 index 000000000000..00d380bace6d --- /dev/null +++ b/test-infra/camel-test-infra-mcp-everything/src/main/java/org/apache/camel/test/infra/mcp/everything/services/McpEverythingSseService.java @@ -0,0 +1,26 @@ +/* + * 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.test.infra.mcp.everything.services; + +import org.apache.camel.test.infra.common.services.ContainerTestService; +import org.apache.camel.test.infra.common.services.TestService; + +/** + * Test infra service for the MCP Everything Server running in SSE transport mode. + */ +public interface McpEverythingSseService extends TestService, McpEverythingSseInfraService, ContainerTestService { +}
