diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ai-tool.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ai-tool.json index 8f3f695b609ae..58947584ff449 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ai-tool.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/ai-tool.json @@ -31,11 +31,13 @@ "destructiveHint": { "index": 4, "kind": "property", "displayName": "Destructive Hint", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "java.lang.Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "MCP hint that the tool may perform destructive or irreversible updates. Advisory for MCP clients; not enforced by Camel." }, "idempotentHint": { "index": 5, "kind": "property", "displayName": "Idempotent Hint", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "java.lang.Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "MCP hint that repeating the tool call with the same arguments has no additional effect. Advisory for MCP clients; not enforced by Camel." }, "openWorldHint": { "index": 6, "kind": "property", "displayName": "Open World Hint", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "java.lang.Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "MCP hint that the tool interacts with external systems outside the application's control. Advisory for MCP clients; not enforced by Camel." }, - "parameters": { "index": 7, "kind": "property", "displayName": "Parameters", "group": "consumer", "label": "consumer", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "parameter.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Tool input parameters. Format: parameter.NAME=TYPE, parameter.NAME.description=TEXT, parameter.NAME.required=true or false, parameter.NAME.enum=val1,val2. Supported types: string, integer, number, boolean. Mutually exclusive with argSchema. This is a multi-value option with prefix: parameter." }, - "readOnlyHint": { "index": 8, "kind": "property", "displayName": "Read Only Hint", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "java.lang.Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "MCP hint that the tool only reads data and does not modify state. Advisory for MCP clients; not enforced by Camel." }, - "tags": { "index": 9, "kind": "property", "displayName": "Tags", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Comma-separated list of tags used to group tools. Producers filter the registry by these tags to select which tools to expose to the LLM. When omitted, the tool goes into a default pool available to all producers." }, - "title": { "index": 10, "kind": "property", "displayName": "Title", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Optional display title for MCP tool listings. Advisory hint for MCP clients only." }, - "autowiredEnabled": { "index": 11, "kind": "property", "displayName": "Autowired Enabled", "group": "advanced", "label": "advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": true, "description": "Whether autowiring is enabled. This is used for automatic autowiring options (the option must be marked as autowired) by looking up in the registry to find if there is a single instance of matching type, which then gets configured on the component. This can be used for automatic configuring JDBC data sources, JMS connection factories, AWS Clients, etc." } + "outputParameters": { "index": 7, "kind": "property", "displayName": "Output Parameters", "group": "consumer", "label": "consumer", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "outputParameter.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Tool output schema fields. Format: outputParameter.NAME=TYPE, outputParameter.NAME.description=TEXT. Supported types: string, integer, number, boolean. Mutually exclusive with outputSchema. This is a multi-value option with prefix: outputParameter." }, + "outputSchema": { "index": 8, "kind": "property", "displayName": "Output Schema", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "supportFileReference": true, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Raw JSON Schema describing the tool's structured output. Supports inline JSON and resource references (classpath:, file:, resource:). Mutually exclusive with the outputParameter multi-value options. When declared, the route body is parsed as JSON and exposed as structured content to MCP clients." }, + "parameters": { "index": 9, "kind": "property", "displayName": "Parameters", "group": "consumer", "label": "consumer", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "parameter.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Tool input parameters. Format: parameter.NAME=TYPE, parameter.NAME.description=TEXT, parameter.NAME.required=true or false, parameter.NAME.enum=val1,val2. Supported types: string, integer, number, boolean. Mutually exclusive with argSchema. This is a multi-value option with prefix: parameter." }, + "readOnlyHint": { "index": 10, "kind": "property", "displayName": "Read Only Hint", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "java.lang.Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "MCP hint that the tool only reads data and does not modify state. Advisory for MCP clients; not enforced by Camel." }, + "tags": { "index": 11, "kind": "property", "displayName": "Tags", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Comma-separated list of tags used to group tools. Producers filter the registry by these tags to select which tools to expose to the LLM. When omitted, the tool goes into a default pool available to all producers." }, + "title": { "index": 12, "kind": "property", "displayName": "Title", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Optional display title for MCP tool listings. Advisory hint for MCP clients only." }, + "autowiredEnabled": { "index": 13, "kind": "property", "displayName": "Autowired Enabled", "group": "advanced", "label": "advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": true, "description": "Whether autowiring is enabled. This is used for automatic autowiring options (the option must be marked as autowired) by looking up in the registry to find if there is a single instance of matching type, which then gets configured on the component. This can be used for automatic configuring JDBC data sources, JMS connection factories, AWS Clients, etc." } }, "properties": { "toolName": { "index": 0, "kind": "path", "displayName": "Tool Name", "group": "consumer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The tool name. This is the name the LLM sees and uses to invoke the tool." }, @@ -44,12 +46,14 @@ "destructiveHint": { "index": 3, "kind": "parameter", "displayName": "Destructive Hint", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "java.lang.Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "MCP hint that the tool may perform destructive or irreversible updates. Advisory for MCP clients; not enforced by Camel." }, "idempotentHint": { "index": 4, "kind": "parameter", "displayName": "Idempotent Hint", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "java.lang.Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "MCP hint that repeating the tool call with the same arguments has no additional effect. Advisory for MCP clients; not enforced by Camel." }, "openWorldHint": { "index": 5, "kind": "parameter", "displayName": "Open World Hint", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "java.lang.Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "MCP hint that the tool interacts with external systems outside the application's control. Advisory for MCP clients; not enforced by Camel." }, - "parameters": { "index": 6, "kind": "parameter", "displayName": "Parameters", "group": "consumer", "label": "consumer", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "parameter.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Tool input parameters. Format: parameter.NAME=TYPE, parameter.NAME.description=TEXT, parameter.NAME.required=true or false, parameter.NAME.enum=val1,val2. Supported types: string, integer, number, boolean. Mutually exclusive with argSchema. This is a multi-value option with prefix: parameter." }, - "readOnlyHint": { "index": 7, "kind": "parameter", "displayName": "Read Only Hint", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "java.lang.Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "MCP hint that the tool only reads data and does not modify state. Advisory for MCP clients; not enforced by Camel." }, - "tags": { "index": 8, "kind": "parameter", "displayName": "Tags", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Comma-separated list of tags used to group tools. Producers filter the registry by these tags to select which tools to expose to the LLM. When omitted, the tool goes into a default pool available to all producers." }, - "title": { "index": 9, "kind": "parameter", "displayName": "Title", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Optional display title for MCP tool listings. Advisory hint for MCP clients only." }, - "bridgeErrorHandler": { "index": 10, "kind": "parameter", "displayName": "Bridge Error Handler", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Allows for bridging the consumer to the Camel routing Error Handler, which mean any exceptions (if possible) occurred while the Camel consumer is trying to pickup incoming messages, or the likes, will now be processed as a message and handled by the routing Error Handler. Important: This is only possible if the 3rd party component allows Camel to be alerted if an exception was thrown. Some components handle this internally only, and therefore bridgeErrorHandler is not possible. In other situations we may improve the Camel component to hook into the 3rd party component and make this possible for future releases. By default the consumer will use the org.apache.camel.spi.ExceptionHandler to deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, - "exceptionHandler": { "index": 11, "kind": "parameter", "displayName": "Exception Handler", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "object", "javaType": "org.apache.camel.spi.ExceptionHandler", "optionalPrefix": "consumer.", "deprecated": false, "autowired": false, "secret": false, "description": "To let the consumer use a custom ExceptionHandler. Notice if the option bridgeErrorHandler is enabled then this option is not in use. By default the consumer will deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, - "exchangePattern": { "index": 12, "kind": "parameter", "displayName": "Exchange Pattern", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "enum", "javaType": "org.apache.camel.ExchangePattern", "enum": [ "InOnly", "InOut" ], "deprecated": false, "autowired": false, "secret": false, "description": "Sets the exchange pattern when the consumer creates an exchange." } + "outputParameters": { "index": 6, "kind": "parameter", "displayName": "Output Parameters", "group": "consumer", "label": "consumer", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "outputParameter.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Tool output schema fields. Format: outputParameter.NAME=TYPE, outputParameter.NAME.description=TEXT. Supported types: string, integer, number, boolean. Mutually exclusive with outputSchema. This is a multi-value option with prefix: outputParameter." }, + "outputSchema": { "index": 7, "kind": "parameter", "displayName": "Output Schema", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "supportFileReference": true, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Raw JSON Schema describing the tool's structured output. Supports inline JSON and resource references (classpath:, file:, resource:). Mutually exclusive with the outputParameter multi-value options. When declared, the route body is parsed as JSON and exposed as structured content to MCP clients." }, + "parameters": { "index": 8, "kind": "parameter", "displayName": "Parameters", "group": "consumer", "label": "consumer", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "parameter.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Tool input parameters. Format: parameter.NAME=TYPE, parameter.NAME.description=TEXT, parameter.NAME.required=true or false, parameter.NAME.enum=val1,val2. Supported types: string, integer, number, boolean. Mutually exclusive with argSchema. This is a multi-value option with prefix: parameter." }, + "readOnlyHint": { "index": 9, "kind": "parameter", "displayName": "Read Only Hint", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "java.lang.Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "MCP hint that the tool only reads data and does not modify state. Advisory for MCP clients; not enforced by Camel." }, + "tags": { "index": 10, "kind": "parameter", "displayName": "Tags", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Comma-separated list of tags used to group tools. Producers filter the registry by these tags to select which tools to expose to the LLM. When omitted, the tool goes into a default pool available to all producers." }, + "title": { "index": 11, "kind": "parameter", "displayName": "Title", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Optional display title for MCP tool listings. Advisory hint for MCP clients only." }, + "bridgeErrorHandler": { "index": 12, "kind": "parameter", "displayName": "Bridge Error Handler", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Allows for bridging the consumer to the Camel routing Error Handler, which mean any exceptions (if possible) occurred while the Camel consumer is trying to pickup incoming messages, or the likes, will now be processed as a message and handled by the routing Error Handler. Important: This is only possible if the 3rd party component allows Camel to be alerted if an exception was thrown. Some components handle this internally only, and therefore bridgeErrorHandler is not possible. In other situations we may improve the Camel component to hook into the 3rd party component and make this possible for future releases. By default the consumer will use the org.apache.camel.spi.ExceptionHandler to deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, + "exceptionHandler": { "index": 13, "kind": "parameter", "displayName": "Exception Handler", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "object", "javaType": "org.apache.camel.spi.ExceptionHandler", "optionalPrefix": "consumer.", "deprecated": false, "autowired": false, "secret": false, "description": "To let the consumer use a custom ExceptionHandler. Notice if the option bridgeErrorHandler is enabled then this option is not in use. By default the consumer will deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, + "exchangePattern": { "index": 14, "kind": "parameter", "displayName": "Exchange Pattern", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "enum", "javaType": "org.apache.camel.ExchangePattern", "enum": [ "InOnly", "InOut" ], "deprecated": false, "autowired": false, "secret": false, "description": "Sets the exchange pattern when the consumer creates an exchange." } } } diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc index defb3f0a929cf..c13661511ada5 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-tool-component.adoc @@ -197,6 +197,50 @@ Top-level schema properties are exposed as exchange headers. Nested values are p The root schema must be a JSON Schema object with a top-level `properties` map. Camel always allowlists top-level property names when invoking the tool route, even if the schema sets `additionalProperties: true`. Nested fields are not flattened into headers; only top-level properties become exchange headers (Map/List/primitive values). +=== Structured Tool Output (outputSchema) + +MCP tools can declare an `outputSchema` and return `structuredContent` (typed JSON) so clients parse tool results reliably instead of re-interpreting free text. Use `outputParameter.*` for flat field definitions or `outputSchema` for raw JSON Schema (mirroring the input-side `parameter.*` / `argSchema` pattern). The two options are mutually exclusive. + +When an output schema is declared, the route body must be JSON (a JSON string, `Map`, or `List`). Camel parses it into structured content and forwards it through the MCP bridge as `CallToolResult.structuredContent`. The text representation remains available for LLM adapters (LangChain4j, Spring AI) that consume string tool results. + +[tabs] +==== +Java:: ++ +[source,java] +---- +from("ai-tool:getWeather?tags=weather&description=Get weather" + + "&outputSchema=classpath:schemas/weather-result.json") + .process(exchange -> exchange.getMessage().setBody( + "{\"temperature\":21.5,\"unit\":\"celsius\"}")); +---- +YAML:: ++ +[source,yaml] +---- +- route: + from: + uri: ai-tool:getWeather + parameters: + tags: weather + description: "Get weather" + outputParameter.temperature: number + outputParameter.unit: string + steps: + - setBody: + constant: '{"temperature":21.5,"unit":"celsius"}' +---- +==== + +Flat `outputParameter.*` options use the same sub-option syntax as input `parameter.*`: + +* `outputParameter.NAME=TYPE` — field type (`string`, `integer`, `number`, `boolean`) +* `outputParameter.NAME.description=TEXT` — field description in the generated JSON Schema +* `outputParameter.NAME.required=true` — marks the field as required in the output schema +* `outputParameter.NAME.enum=val1,val2` — restricts allowed values + +`outputSchema` supports Camel resource references such as `classpath:schemas/weather-result.json`. + === MCP Tool Annotation Hints When exposing `ai-tool` routes through the MCP Server component, you can declare optional behavioral hints aligned with the MCP specification (`ToolAnnotations`). MCP clients may use these hints for per-tool policy (for example auto-approving read-only tools or requiring confirmation before destructive ones). diff --git a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolComponentConfigurer.java b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolComponentConfigurer.java index f5a2d7183da4a..bf7b00f520bd8 100644 --- a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolComponentConfigurer.java +++ b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolComponentConfigurer.java @@ -44,6 +44,10 @@ public boolean configure(CamelContext camelContext, Object obj, String name, Obj case "idempotentHint": getOrCreateConfiguration(target).setIdempotentHint(property(camelContext, java.lang.Boolean.class, value)); return true; case "openworldhint": case "openWorldHint": getOrCreateConfiguration(target).setOpenWorldHint(property(camelContext, java.lang.Boolean.class, value)); return true; + case "outputparameters": + case "outputParameters": getOrCreateConfiguration(target).setOutputParameters(property(camelContext, java.util.Map.class, value)); return true; + case "outputschema": + case "outputSchema": getOrCreateConfiguration(target).setOutputSchema(property(camelContext, java.lang.String.class, value)); return true; case "parameters": getOrCreateConfiguration(target).setParameters(property(camelContext, java.util.Map.class, value)); return true; case "readonlyhint": case "readOnlyHint": getOrCreateConfiguration(target).setReadOnlyHint(property(camelContext, java.lang.Boolean.class, value)); return true; @@ -70,6 +74,10 @@ public Class getOptionType(String name, boolean ignoreCase) { case "idempotentHint": return java.lang.Boolean.class; case "openworldhint": case "openWorldHint": return java.lang.Boolean.class; + case "outputparameters": + case "outputParameters": return java.util.Map.class; + case "outputschema": + case "outputSchema": return java.lang.String.class; case "parameters": return java.util.Map.class; case "readonlyhint": case "readOnlyHint": return java.lang.Boolean.class; @@ -97,6 +105,10 @@ public Object getOptionValue(Object obj, String name, boolean ignoreCase) { case "idempotentHint": return getOrCreateConfiguration(target).getIdempotentHint(); case "openworldhint": case "openWorldHint": return getOrCreateConfiguration(target).getOpenWorldHint(); + case "outputparameters": + case "outputParameters": return getOrCreateConfiguration(target).getOutputParameters(); + case "outputschema": + case "outputSchema": return getOrCreateConfiguration(target).getOutputSchema(); case "parameters": return getOrCreateConfiguration(target).getParameters(); case "readonlyhint": case "readOnlyHint": return getOrCreateConfiguration(target).getReadOnlyHint(); @@ -109,6 +121,8 @@ public Object getOptionValue(Object obj, String name, boolean ignoreCase) { @Override public Object getCollectionValueType(Object target, String name, boolean ignoreCase) { switch (ignoreCase ? name.toLowerCase() : name) { + case "outputparameters": + case "outputParameters": return java.lang.String.class; case "parameters": return java.lang.String.class; default: return null; } diff --git a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolConfigurationConfigurer.java b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolConfigurationConfigurer.java index d20e0c4742a12..487f382afa7ab 100644 --- a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolConfigurationConfigurer.java +++ b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolConfigurationConfigurer.java @@ -32,6 +32,10 @@ public boolean configure(CamelContext camelContext, Object obj, String name, Obj case "idempotentHint": target.setIdempotentHint(property(camelContext, java.lang.Boolean.class, value)); return true; case "openworldhint": case "openWorldHint": target.setOpenWorldHint(property(camelContext, java.lang.Boolean.class, value)); return true; + case "outputparameters": + case "outputParameters": target.setOutputParameters(property(camelContext, java.util.Map.class, value)); return true; + case "outputschema": + case "outputSchema": target.setOutputSchema(property(camelContext, java.lang.String.class, value)); return true; case "parameters": target.setParameters(property(camelContext, java.util.Map.class, value)); return true; case "readonlyhint": case "readOnlyHint": target.setReadOnlyHint(property(camelContext, java.lang.Boolean.class, value)); return true; @@ -53,6 +57,10 @@ public Class getOptionType(String name, boolean ignoreCase) { case "idempotentHint": return java.lang.Boolean.class; case "openworldhint": case "openWorldHint": return java.lang.Boolean.class; + case "outputparameters": + case "outputParameters": return java.util.Map.class; + case "outputschema": + case "outputSchema": return java.lang.String.class; case "parameters": return java.util.Map.class; case "readonlyhint": case "readOnlyHint": return java.lang.Boolean.class; @@ -75,6 +83,10 @@ public Object getOptionValue(Object obj, String name, boolean ignoreCase) { case "idempotentHint": return target.getIdempotentHint(); case "openworldhint": case "openWorldHint": return target.getOpenWorldHint(); + case "outputparameters": + case "outputParameters": return target.getOutputParameters(); + case "outputschema": + case "outputSchema": return target.getOutputSchema(); case "parameters": return target.getParameters(); case "readonlyhint": case "readOnlyHint": return target.getReadOnlyHint(); @@ -87,6 +99,8 @@ public Object getOptionValue(Object obj, String name, boolean ignoreCase) { @Override public Object getCollectionValueType(Object target, String name, boolean ignoreCase) { switch (ignoreCase ? name.toLowerCase() : name) { + case "outputparameters": + case "outputParameters": return java.lang.String.class; case "parameters": return java.lang.String.class; default: return null; } diff --git a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointConfigurer.java b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointConfigurer.java index d369d659852a0..51216e1a274b4 100644 --- a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointConfigurer.java +++ b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointConfigurer.java @@ -38,6 +38,10 @@ public boolean configure(CamelContext camelContext, Object obj, String name, Obj case "idempotentHint": target.getConfiguration().setIdempotentHint(property(camelContext, java.lang.Boolean.class, value)); return true; case "openworldhint": case "openWorldHint": target.getConfiguration().setOpenWorldHint(property(camelContext, java.lang.Boolean.class, value)); return true; + case "outputparameters": + case "outputParameters": target.getConfiguration().setOutputParameters(property(camelContext, java.util.Map.class, value)); return true; + case "outputschema": + case "outputSchema": target.getConfiguration().setOutputSchema(property(camelContext, java.lang.String.class, value)); return true; case "parameters": target.getConfiguration().setParameters(property(camelContext, java.util.Map.class, value)); return true; case "readonlyhint": case "readOnlyHint": target.getConfiguration().setReadOnlyHint(property(camelContext, java.lang.Boolean.class, value)); return true; @@ -65,6 +69,10 @@ public Class getOptionType(String name, boolean ignoreCase) { case "idempotentHint": return java.lang.Boolean.class; case "openworldhint": case "openWorldHint": return java.lang.Boolean.class; + case "outputparameters": + case "outputParameters": return java.util.Map.class; + case "outputschema": + case "outputSchema": return java.lang.String.class; case "parameters": return java.util.Map.class; case "readonlyhint": case "readOnlyHint": return java.lang.Boolean.class; @@ -93,6 +101,10 @@ public Object getOptionValue(Object obj, String name, boolean ignoreCase) { case "idempotentHint": return target.getConfiguration().getIdempotentHint(); case "openworldhint": case "openWorldHint": return target.getConfiguration().getOpenWorldHint(); + case "outputparameters": + case "outputParameters": return target.getConfiguration().getOutputParameters(); + case "outputschema": + case "outputSchema": return target.getConfiguration().getOutputSchema(); case "parameters": return target.getConfiguration().getParameters(); case "readonlyhint": case "readOnlyHint": return target.getConfiguration().getReadOnlyHint(); @@ -105,6 +117,8 @@ public Object getOptionValue(Object obj, String name, boolean ignoreCase) { @Override public Object getCollectionValueType(Object target, String name, boolean ignoreCase) { switch (ignoreCase ? name.toLowerCase() : name) { + case "outputparameters": + case "outputParameters": return java.lang.String.class; case "parameters": return java.lang.String.class; default: return null; } diff --git a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointUriFactory.java b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointUriFactory.java index 0a4c6a4f9e2c7..0b573f3a064a4 100644 --- a/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointUriFactory.java +++ b/components/camel-ai/camel-ai-tool/src/generated/java/org/apache/camel/component/ai/tool/AiToolEndpointUriFactory.java @@ -24,7 +24,7 @@ public class AiToolEndpointUriFactory extends org.apache.camel.support.component private static final Set ENDPOINT_IDENTITY_PROPERTY_NAMES; private static final Map MULTI_VALUE_PREFIXES; static { - Set props = new HashSet<>(13); + Set props = new HashSet<>(15); props.add("argSchema"); props.add("bridgeErrorHandler"); props.add("description"); @@ -33,6 +33,8 @@ public class AiToolEndpointUriFactory extends org.apache.camel.support.component props.add("exchangePattern"); props.add("idempotentHint"); props.add("openWorldHint"); + props.add("outputParameters"); + props.add("outputSchema"); props.add("parameters"); props.add("readOnlyHint"); props.add("tags"); @@ -41,7 +43,8 @@ public class AiToolEndpointUriFactory extends org.apache.camel.support.component PROPERTY_NAMES = Collections.unmodifiableSet(props); SECRET_PROPERTY_NAMES = Collections.emptySet(); ENDPOINT_IDENTITY_PROPERTY_NAMES = Collections.emptySet(); - Map prefixes = new HashMap<>(1); + Map prefixes = new HashMap<>(2); + prefixes.put("outputParameters", "outputParameter."); prefixes.put("parameters", "parameter."); MULTI_VALUE_PREFIXES = Collections.unmodifiableMap(prefixes); } diff --git a/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/org/apache/camel/component/ai/tool/ai-tool.json b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/org/apache/camel/component/ai/tool/ai-tool.json index 8f3f695b609ae..58947584ff449 100644 --- a/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/org/apache/camel/component/ai/tool/ai-tool.json +++ b/components/camel-ai/camel-ai-tool/src/generated/resources/META-INF/org/apache/camel/component/ai/tool/ai-tool.json @@ -31,11 +31,13 @@ "destructiveHint": { "index": 4, "kind": "property", "displayName": "Destructive Hint", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "java.lang.Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "MCP hint that the tool may perform destructive or irreversible updates. Advisory for MCP clients; not enforced by Camel." }, "idempotentHint": { "index": 5, "kind": "property", "displayName": "Idempotent Hint", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "java.lang.Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "MCP hint that repeating the tool call with the same arguments has no additional effect. Advisory for MCP clients; not enforced by Camel." }, "openWorldHint": { "index": 6, "kind": "property", "displayName": "Open World Hint", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "java.lang.Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "MCP hint that the tool interacts with external systems outside the application's control. Advisory for MCP clients; not enforced by Camel." }, - "parameters": { "index": 7, "kind": "property", "displayName": "Parameters", "group": "consumer", "label": "consumer", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "parameter.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Tool input parameters. Format: parameter.NAME=TYPE, parameter.NAME.description=TEXT, parameter.NAME.required=true or false, parameter.NAME.enum=val1,val2. Supported types: string, integer, number, boolean. Mutually exclusive with argSchema. This is a multi-value option with prefix: parameter." }, - "readOnlyHint": { "index": 8, "kind": "property", "displayName": "Read Only Hint", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "java.lang.Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "MCP hint that the tool only reads data and does not modify state. Advisory for MCP clients; not enforced by Camel." }, - "tags": { "index": 9, "kind": "property", "displayName": "Tags", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Comma-separated list of tags used to group tools. Producers filter the registry by these tags to select which tools to expose to the LLM. When omitted, the tool goes into a default pool available to all producers." }, - "title": { "index": 10, "kind": "property", "displayName": "Title", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Optional display title for MCP tool listings. Advisory hint for MCP clients only." }, - "autowiredEnabled": { "index": 11, "kind": "property", "displayName": "Autowired Enabled", "group": "advanced", "label": "advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": true, "description": "Whether autowiring is enabled. This is used for automatic autowiring options (the option must be marked as autowired) by looking up in the registry to find if there is a single instance of matching type, which then gets configured on the component. This can be used for automatic configuring JDBC data sources, JMS connection factories, AWS Clients, etc." } + "outputParameters": { "index": 7, "kind": "property", "displayName": "Output Parameters", "group": "consumer", "label": "consumer", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "outputParameter.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Tool output schema fields. Format: outputParameter.NAME=TYPE, outputParameter.NAME.description=TEXT. Supported types: string, integer, number, boolean. Mutually exclusive with outputSchema. This is a multi-value option with prefix: outputParameter." }, + "outputSchema": { "index": 8, "kind": "property", "displayName": "Output Schema", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "supportFileReference": true, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Raw JSON Schema describing the tool's structured output. Supports inline JSON and resource references (classpath:, file:, resource:). Mutually exclusive with the outputParameter multi-value options. When declared, the route body is parsed as JSON and exposed as structured content to MCP clients." }, + "parameters": { "index": 9, "kind": "property", "displayName": "Parameters", "group": "consumer", "label": "consumer", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "parameter.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Tool input parameters. Format: parameter.NAME=TYPE, parameter.NAME.description=TEXT, parameter.NAME.required=true or false, parameter.NAME.enum=val1,val2. Supported types: string, integer, number, boolean. Mutually exclusive with argSchema. This is a multi-value option with prefix: parameter." }, + "readOnlyHint": { "index": 10, "kind": "property", "displayName": "Read Only Hint", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "java.lang.Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "MCP hint that the tool only reads data and does not modify state. Advisory for MCP clients; not enforced by Camel." }, + "tags": { "index": 11, "kind": "property", "displayName": "Tags", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Comma-separated list of tags used to group tools. Producers filter the registry by these tags to select which tools to expose to the LLM. When omitted, the tool goes into a default pool available to all producers." }, + "title": { "index": 12, "kind": "property", "displayName": "Title", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Optional display title for MCP tool listings. Advisory hint for MCP clients only." }, + "autowiredEnabled": { "index": 13, "kind": "property", "displayName": "Autowired Enabled", "group": "advanced", "label": "advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": true, "description": "Whether autowiring is enabled. This is used for automatic autowiring options (the option must be marked as autowired) by looking up in the registry to find if there is a single instance of matching type, which then gets configured on the component. This can be used for automatic configuring JDBC data sources, JMS connection factories, AWS Clients, etc." } }, "properties": { "toolName": { "index": 0, "kind": "path", "displayName": "Tool Name", "group": "consumer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The tool name. This is the name the LLM sees and uses to invoke the tool." }, @@ -44,12 +46,14 @@ "destructiveHint": { "index": 3, "kind": "parameter", "displayName": "Destructive Hint", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "java.lang.Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "MCP hint that the tool may perform destructive or irreversible updates. Advisory for MCP clients; not enforced by Camel." }, "idempotentHint": { "index": 4, "kind": "parameter", "displayName": "Idempotent Hint", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "java.lang.Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "MCP hint that repeating the tool call with the same arguments has no additional effect. Advisory for MCP clients; not enforced by Camel." }, "openWorldHint": { "index": 5, "kind": "parameter", "displayName": "Open World Hint", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "java.lang.Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "MCP hint that the tool interacts with external systems outside the application's control. Advisory for MCP clients; not enforced by Camel." }, - "parameters": { "index": 6, "kind": "parameter", "displayName": "Parameters", "group": "consumer", "label": "consumer", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "parameter.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Tool input parameters. Format: parameter.NAME=TYPE, parameter.NAME.description=TEXT, parameter.NAME.required=true or false, parameter.NAME.enum=val1,val2. Supported types: string, integer, number, boolean. Mutually exclusive with argSchema. This is a multi-value option with prefix: parameter." }, - "readOnlyHint": { "index": 7, "kind": "parameter", "displayName": "Read Only Hint", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "java.lang.Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "MCP hint that the tool only reads data and does not modify state. Advisory for MCP clients; not enforced by Camel." }, - "tags": { "index": 8, "kind": "parameter", "displayName": "Tags", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Comma-separated list of tags used to group tools. Producers filter the registry by these tags to select which tools to expose to the LLM. When omitted, the tool goes into a default pool available to all producers." }, - "title": { "index": 9, "kind": "parameter", "displayName": "Title", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Optional display title for MCP tool listings. Advisory hint for MCP clients only." }, - "bridgeErrorHandler": { "index": 10, "kind": "parameter", "displayName": "Bridge Error Handler", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Allows for bridging the consumer to the Camel routing Error Handler, which mean any exceptions (if possible) occurred while the Camel consumer is trying to pickup incoming messages, or the likes, will now be processed as a message and handled by the routing Error Handler. Important: This is only possible if the 3rd party component allows Camel to be alerted if an exception was thrown. Some components handle this internally only, and therefore bridgeErrorHandler is not possible. In other situations we may improve the Camel component to hook into the 3rd party component and make this possible for future releases. By default the consumer will use the org.apache.camel.spi.ExceptionHandler to deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, - "exceptionHandler": { "index": 11, "kind": "parameter", "displayName": "Exception Handler", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "object", "javaType": "org.apache.camel.spi.ExceptionHandler", "optionalPrefix": "consumer.", "deprecated": false, "autowired": false, "secret": false, "description": "To let the consumer use a custom ExceptionHandler. Notice if the option bridgeErrorHandler is enabled then this option is not in use. By default the consumer will deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, - "exchangePattern": { "index": 12, "kind": "parameter", "displayName": "Exchange Pattern", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "enum", "javaType": "org.apache.camel.ExchangePattern", "enum": [ "InOnly", "InOut" ], "deprecated": false, "autowired": false, "secret": false, "description": "Sets the exchange pattern when the consumer creates an exchange." } + "outputParameters": { "index": 6, "kind": "parameter", "displayName": "Output Parameters", "group": "consumer", "label": "consumer", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "outputParameter.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Tool output schema fields. Format: outputParameter.NAME=TYPE, outputParameter.NAME.description=TEXT. Supported types: string, integer, number, boolean. Mutually exclusive with outputSchema. This is a multi-value option with prefix: outputParameter." }, + "outputSchema": { "index": 7, "kind": "parameter", "displayName": "Output Schema", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "supportFileReference": true, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Raw JSON Schema describing the tool's structured output. Supports inline JSON and resource references (classpath:, file:, resource:). Mutually exclusive with the outputParameter multi-value options. When declared, the route body is parsed as JSON and exposed as structured content to MCP clients." }, + "parameters": { "index": 8, "kind": "parameter", "displayName": "Parameters", "group": "consumer", "label": "consumer", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "parameter.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Tool input parameters. Format: parameter.NAME=TYPE, parameter.NAME.description=TEXT, parameter.NAME.required=true or false, parameter.NAME.enum=val1,val2. Supported types: string, integer, number, boolean. Mutually exclusive with argSchema. This is a multi-value option with prefix: parameter." }, + "readOnlyHint": { "index": 9, "kind": "parameter", "displayName": "Read Only Hint", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "java.lang.Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "MCP hint that the tool only reads data and does not modify state. Advisory for MCP clients; not enforced by Camel." }, + "tags": { "index": 10, "kind": "parameter", "displayName": "Tags", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Comma-separated list of tags used to group tools. Producers filter the registry by these tags to select which tools to expose to the LLM. When omitted, the tool goes into a default pool available to all producers." }, + "title": { "index": 11, "kind": "parameter", "displayName": "Title", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.ai.tool.AiToolConfiguration", "configurationField": "configuration", "description": "Optional display title for MCP tool listings. Advisory hint for MCP clients only." }, + "bridgeErrorHandler": { "index": 12, "kind": "parameter", "displayName": "Bridge Error Handler", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Allows for bridging the consumer to the Camel routing Error Handler, which mean any exceptions (if possible) occurred while the Camel consumer is trying to pickup incoming messages, or the likes, will now be processed as a message and handled by the routing Error Handler. Important: This is only possible if the 3rd party component allows Camel to be alerted if an exception was thrown. Some components handle this internally only, and therefore bridgeErrorHandler is not possible. In other situations we may improve the Camel component to hook into the 3rd party component and make this possible for future releases. By default the consumer will use the org.apache.camel.spi.ExceptionHandler to deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, + "exceptionHandler": { "index": 13, "kind": "parameter", "displayName": "Exception Handler", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "object", "javaType": "org.apache.camel.spi.ExceptionHandler", "optionalPrefix": "consumer.", "deprecated": false, "autowired": false, "secret": false, "description": "To let the consumer use a custom ExceptionHandler. Notice if the option bridgeErrorHandler is enabled then this option is not in use. By default the consumer will deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, + "exchangePattern": { "index": 14, "kind": "parameter", "displayName": "Exchange Pattern", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "enum", "javaType": "org.apache.camel.ExchangePattern", "enum": [ "InOnly", "InOut" ], "deprecated": false, "autowired": false, "secret": false, "description": "Sets the exchange pattern when the consumer creates an exchange." } } } diff --git a/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc b/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc index defb3f0a929cf..c13661511ada5 100644 --- a/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc +++ b/components/camel-ai/camel-ai-tool/src/main/docs/ai-tool-component.adoc @@ -197,6 +197,50 @@ Top-level schema properties are exposed as exchange headers. Nested values are p The root schema must be a JSON Schema object with a top-level `properties` map. Camel always allowlists top-level property names when invoking the tool route, even if the schema sets `additionalProperties: true`. Nested fields are not flattened into headers; only top-level properties become exchange headers (Map/List/primitive values). +=== Structured Tool Output (outputSchema) + +MCP tools can declare an `outputSchema` and return `structuredContent` (typed JSON) so clients parse tool results reliably instead of re-interpreting free text. Use `outputParameter.*` for flat field definitions or `outputSchema` for raw JSON Schema (mirroring the input-side `parameter.*` / `argSchema` pattern). The two options are mutually exclusive. + +When an output schema is declared, the route body must be JSON (a JSON string, `Map`, or `List`). Camel parses it into structured content and forwards it through the MCP bridge as `CallToolResult.structuredContent`. The text representation remains available for LLM adapters (LangChain4j, Spring AI) that consume string tool results. + +[tabs] +==== +Java:: ++ +[source,java] +---- +from("ai-tool:getWeather?tags=weather&description=Get weather" + + "&outputSchema=classpath:schemas/weather-result.json") + .process(exchange -> exchange.getMessage().setBody( + "{\"temperature\":21.5,\"unit\":\"celsius\"}")); +---- +YAML:: ++ +[source,yaml] +---- +- route: + from: + uri: ai-tool:getWeather + parameters: + tags: weather + description: "Get weather" + outputParameter.temperature: number + outputParameter.unit: string + steps: + - setBody: + constant: '{"temperature":21.5,"unit":"celsius"}' +---- +==== + +Flat `outputParameter.*` options use the same sub-option syntax as input `parameter.*`: + +* `outputParameter.NAME=TYPE` — field type (`string`, `integer`, `number`, `boolean`) +* `outputParameter.NAME.description=TEXT` — field description in the generated JSON Schema +* `outputParameter.NAME.required=true` — marks the field as required in the output schema +* `outputParameter.NAME.enum=val1,val2` — restricts allowed values + +`outputSchema` supports Camel resource references such as `classpath:schemas/weather-result.json`. + === MCP Tool Annotation Hints When exposing `ai-tool` routes through the MCP Server component, you can declare optional behavioral hints aligned with the MCP specification (`ToolAnnotations`). MCP clients may use these hints for per-tool policy (for example auto-approving read-only tools or requiring confirmation before destructive ones). diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolComponent.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolComponent.java index da179d5f03414..007bcc1156967 100644 --- a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolComponent.java +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolComponent.java @@ -70,6 +70,12 @@ protected Endpoint createEndpoint(String uri, String remaining, Map e.getValue().toString()))); } + Map outputToolParameters = PropertiesHelper.extractProperties(parameters, "outputParameter."); + if (!outputToolParameters.isEmpty()) { + config.setOutputParameters(outputToolParameters.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().toString()))); + } + AiToolEndpoint endpoint = new AiToolEndpoint(uri, this, toolName, config); setProperties(endpoint, parameters); diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConfiguration.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConfiguration.java index 715ef848e10af..6fcfbb326e0e0 100644 --- a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConfiguration.java +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConfiguration.java @@ -61,6 +61,21 @@ public class AiToolConfiguration implements Cloneable { @Metadata(label = "consumer", supportFileReference = true, largeInput = true, inputLanguage = "json") private String argSchema; + @Metadata(label = "consumer") + @UriParam(description = "Tool output schema fields. " + + "Format: outputParameter.NAME=TYPE, outputParameter.NAME.description=TEXT. " + + "Supported types: string, integer, number, boolean. " + + "Mutually exclusive with outputSchema.", + prefix = "outputParameter.", multiValue = true) + private Map outputParameters; + + @UriParam(description = "Raw JSON Schema describing the tool's structured output. Supports inline JSON and " + + "resource references (classpath:, file:, resource:). Mutually exclusive with the " + + "outputParameter multi-value options. When declared, the route body is parsed as JSON " + + "and exposed as structured content to MCP clients.") + @Metadata(label = "consumer", supportFileReference = true, largeInput = true, inputLanguage = "json") + private String outputSchema; + @Metadata(label = "consumer") @UriParam(description = "Optional display title for MCP tool listings. Advisory hint for MCP clients only.") private String title; @@ -120,6 +135,22 @@ public void setArgSchema(String argSchema) { this.argSchema = argSchema; } + public Map getOutputParameters() { + return outputParameters; + } + + public void setOutputParameters(Map outputParameters) { + this.outputParameters = outputParameters; + } + + public String getOutputSchema() { + return outputSchema; + } + + public void setOutputSchema(String outputSchema) { + this.outputSchema = outputSchema; + } + public String getTitle() { return title; } @@ -166,6 +197,9 @@ public AiToolConfiguration copy() { if (this.parameters != null) { copy.parameters = new HashMap<>(this.parameters); } + if (this.outputParameters != null) { + copy.outputParameters = new HashMap<>(this.outputParameters); + } return copy; } catch (CloneNotSupportedException e) { throw new RuntimeCamelException(e); diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConsumer.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConsumer.java index 1f9c587bb7f40..9309dfe6241a8 100644 --- a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConsumer.java +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolConsumer.java @@ -52,8 +52,14 @@ protected void doStart() throws Exception { String argSchema = configuration.getArgSchema(); AiToolParameterHelper.validateParameterSourceExclusive(params, argSchema); + Map outputParams = configuration.getOutputParameters(); + String outputSchema = configuration.getOutputSchema(); + AiToolParameterHelper.validateOutputSourceExclusive(outputParams, outputSchema); + Map parameterDefs = Map.of(); String jsonSchema = null; + Map outputParameterDefs = Map.of(); + String outputJsonSchema = null; if (params != null && !params.isEmpty()) { parameterDefs = AiToolParameterHelper.parseParameterMetadata(params); @@ -62,6 +68,13 @@ protected void doStart() throws Exception { jsonSchema = AiToolParameterHelper.resolveArgSchema(getEndpoint().getCamelContext(), argSchema); } + if (outputParams != null && !outputParams.isEmpty()) { + outputParameterDefs = AiToolParameterHelper.parseParameterMetadata(outputParams); + outputJsonSchema = AiToolParameterHelper.buildJsonSchemaFromDefs(outputParameterDefs); + } else if (outputSchema != null && !outputSchema.isBlank()) { + outputJsonSchema = AiToolParameterHelper.resolveOutputSchema(getEndpoint().getCamelContext(), outputSchema); + } + String desc = configuration.getDescription(); if (desc == null || desc.isBlank()) { desc = toolName; @@ -69,7 +82,7 @@ protected void doStart() throws Exception { AiToolAnnotations annotations = AiToolAnnotations.fromConfiguration(configuration); registeredSpec = new AiToolSpec( - toolName, desc, parameterDefs, jsonSchema, annotations, this); + toolName, desc, parameterDefs, jsonSchema, outputParameterDefs, outputJsonSchema, annotations, this); String tags = configuration.getTags(); String[] parsedTags = (tags != null && !tags.isBlank()) diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java index 8f918232d77a0..850af4aebc4a4 100644 --- a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolExecutor.java @@ -151,11 +151,28 @@ public static AiToolResult execute(AiToolSpec spec, Map argument String result = exchange.getMessage().getBody(String.class); LOG.debug("Tool '{}' execution completed successfully", toolName); - return new AiToolResult.Success(result != null ? result : "No result"); + return buildSuccessResult(spec, exchange, result); } catch (Exception e) { LOG.error("Error executing tool '{}': {}", toolName, e.getMessage(), e); return new AiToolResult.ExecutionError( String.format("Error executing tool '%s': %s", toolName, e.getMessage()), e); } } + + private static AiToolResult buildSuccessResult(AiToolSpec spec, Exchange exchange, String stringBody) { + String outputSchema = spec.getOutputJsonSchema(); + if (outputSchema == null || outputSchema.isBlank()) { + return new AiToolResult.Success(stringBody != null ? stringBody : "No result"); + } + + Object body = exchange.getMessage().getBody(); + try { + Object structured = AiToolParameterHelper.parseStructuredOutput(body); + String text = AiToolParameterHelper.structuredContentToText(structured, body); + return new AiToolResult.Success(text, structured); + } catch (IllegalArgumentException e) { + return new AiToolResult.ExecutionError( + String.format("Error executing tool '%s': %s", spec.getName(), e.getMessage()), e); + } + } } diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolParameterHelper.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolParameterHelper.java index 2be215949b48f..f80b906305e36 100644 --- a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolParameterHelper.java +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolParameterHelper.java @@ -171,6 +171,18 @@ public static void validateParameterSourceExclusive(Map paramete } } + /** + * Validates that flat {@code outputParameter.*} metadata and {@code outputSchema} are not both configured. + */ + public static void validateOutputSourceExclusive(Map outputParameters, String outputSchema) { + boolean hasOutputParameters = outputParameters != null && !outputParameters.isEmpty(); + boolean hasOutputSchema = outputSchema != null && !outputSchema.isBlank(); + if (hasOutputParameters && hasOutputSchema) { + throw new IllegalArgumentException( + "outputSchema and outputParameter.* are mutually exclusive on ai-tool endpoints"); + } + } + /** * Resolves, validates, and normalizes a raw JSON Schema for tool input. */ @@ -190,6 +202,73 @@ public static String resolveArgSchema(CamelContext camelContext, String argSchem return root.toJson(); } + /** + * Resolves and normalizes a raw JSON Schema describing tool output. Unlike + * {@link #resolveArgSchema(CamelContext, String)}, the schema may describe any JSON type (object, array, string, + * etc.). + */ + public static String resolveOutputSchema(CamelContext camelContext, String outputSchema) { + if (outputSchema == null || outputSchema.isBlank()) { + throw new IllegalArgumentException("outputSchema must not be blank"); + } + + String resolved = camelContext.resolvePropertyPlaceholders(outputSchema); + String content = resolveResourceContent(camelContext, resolved, "outputSchema"); + if (content != null) { + resolved = content; + } + + JsonObject root = parseJsonObject(resolved, outputSchema, "outputSchema"); + return root.toJson(); + } + + /** + * Parses a route body into structured JSON content when an output schema is declared. + */ + public static Object parseStructuredOutput(Object body) { + if (body == null) { + throw new IllegalArgumentException( + "Route body must not be null when an output schema is declared"); + } + if (body instanceof Map || body instanceof List || body instanceof Number || body instanceof Boolean) { + return body; + } + if (body instanceof String text) { + if (text.isBlank()) { + throw new IllegalArgumentException( + "Route body must not be blank when an output schema is declared"); + } + try { + return Jsoner.deserialize(text); + } catch (DeserializationException e) { + throw new IllegalArgumentException( + "Route body must be valid JSON when an output schema is declared", e); + } + } + throw new IllegalArgumentException( + "Route body must be JSON (String, Map, or List) when an output schema is declared, but was: " + + body.getClass().getSimpleName()); + } + + /** + * Serializes structured JSON content to a text representation for LLM adapters. + */ + public static String structuredContentToText(Object structuredContent, Object originalBody) { + if (structuredContent == null) { + return "No result"; + } + if (originalBody instanceof String text && !text.isBlank()) { + return text.trim(); + } + if (structuredContent instanceof JsonObject jsonObject) { + return jsonObject.toJson(); + } + if (structuredContent instanceof JsonArray jsonArray) { + return jsonArray.toJson(); + } + return Jsoner.serialize(structuredContent); + } + /** * Returns top-level property names declared in a JSON Schema object. */ @@ -227,19 +306,23 @@ public static Set extractRequiredPropertyNames(String jsonSchema) { } private static JsonObject parseJsonObject(String json, String originalValue) { + return parseJsonObject(json, originalValue, "argSchema"); + } + + private static JsonObject parseJsonObject(String json, String originalValue, String context) { try { Object parsed = Jsoner.deserialize(json); if (parsed == null) { - throw new IllegalArgumentException("argSchema must be a JSON object, but was: null"); + throw new IllegalArgumentException(context + " must be a JSON object, but was: null"); } if (!(parsed instanceof JsonObject root)) { throw new IllegalArgumentException( - "argSchema must be a JSON object, but was: " + parsed.getClass().getSimpleName()); + context + " must be a JSON object, but was: " + parsed.getClass().getSimpleName()); } return root; } catch (DeserializationException e) { throw new IllegalArgumentException( - "argSchema does not contain valid JSON. Provided value: " + originalValue, e); + context + " does not contain valid JSON. Provided value: " + originalValue, e); } } @@ -295,6 +378,10 @@ private static Collection readRequiredArray(JsonObject root, String context) } private static String resolveResourceContent(CamelContext camelContext, String property) { + return resolveResourceContent(camelContext, property, "argSchema"); + } + + private static String resolveResourceContent(CamelContext camelContext, String property, String context) { try { if (ResourceHelper.hasScheme(property)) { try (InputStream is = ResourceHelper.resolveMandatoryResourceAsInputStream(camelContext, property)) { @@ -307,7 +394,7 @@ private static String resolveResourceContent(CamelContext camelContext, String p } } } catch (IOException e) { - throw new IllegalArgumentException("Failed to load argSchema resource: " + property, e); + throw new IllegalArgumentException("Failed to load " + context + " resource: " + property, e); } catch (Exception e) { // not a resolvable resource URI — fall through and treat as inline JSON content } diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolResult.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolResult.java index 0eb750db9dfb0..0345db189efb0 100644 --- a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolResult.java +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolResult.java @@ -34,9 +34,14 @@ public sealed interface AiToolResult { /** * The route executed successfully and produced a result. * - * @param value the route result as a string + * @param value the route result as a string (JSON text when structured output is configured) + * @param structuredContent typed JSON value when an output schema is declared, otherwise {@code null} */ - record Success(String value) implements AiToolResult { + record Success(String value, Object structuredContent) implements AiToolResult { + + public Success(String value) { + this(value, null); + } } /** diff --git a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolSpec.java b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolSpec.java index 3a21fb3bbe4a1..2ee712b52c628 100644 --- a/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolSpec.java +++ b/components/camel-ai/camel-ai-tool/src/main/java/org/apache/camel/component/ai/tool/AiToolSpec.java @@ -38,6 +38,8 @@ public final class AiToolSpec { private final String description; private final Map parameterDefs; private final String parametersJsonSchema; + private final Map outputParameterDefs; + private final String outputJsonSchema; private final Set declaredArgumentNames; private final Set requiredArgumentNames; private final AiToolAnnotations annotations; @@ -48,12 +50,16 @@ public AiToolSpec( String description, Map parameterDefs, String parametersJsonSchema, + Map outputParameterDefs, + String outputJsonSchema, AiToolAnnotations annotations, DefaultConsumer consumer) { this.name = name; this.description = description; this.parameterDefs = parameterDefs != null ? Collections.unmodifiableMap(parameterDefs) : Map.of(); this.parametersJsonSchema = parametersJsonSchema; + this.outputParameterDefs = outputParameterDefs != null ? Collections.unmodifiableMap(outputParameterDefs) : Map.of(); + this.outputJsonSchema = outputJsonSchema; if (!this.parameterDefs.isEmpty()) { this.declaredArgumentNames = Set.copyOf(this.parameterDefs.keySet()); this.requiredArgumentNames = this.parameterDefs.entrySet().stream() @@ -95,6 +101,20 @@ public String getParametersJsonSchema() { return parametersJsonSchema; } + /** + * Structured output field definitions declared via flat {@code outputParameter.*} metadata. + */ + public Map getOutputParameterDefs() { + return outputParameterDefs; + } + + /** + * Pre-built JSON Schema string describing the tool's structured output, or {@code null} when none is declared. + */ + public String getOutputJsonSchema() { + return outputJsonSchema; + } + /** * Top-level argument names accepted by this tool. Derived from flat parameter definitions or from a raw * {@code argSchema}'s top-level {@code properties} map. @@ -140,6 +160,8 @@ public boolean equals(Object o) { && Objects.equals(description, that.description) && Objects.equals(parameterDefs, that.parameterDefs) && Objects.equals(parametersJsonSchema, that.parametersJsonSchema) + && Objects.equals(outputParameterDefs, that.outputParameterDefs) + && Objects.equals(outputJsonSchema, that.outputJsonSchema) && Objects.equals(declaredArgumentNames, that.declaredArgumentNames) && Objects.equals(requiredArgumentNames, that.requiredArgumentNames) && Objects.equals(annotations, that.annotations) @@ -148,7 +170,8 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(name, description, parameterDefs, parametersJsonSchema, declaredArgumentNames, + return Objects.hash(name, description, parameterDefs, parametersJsonSchema, outputParameterDefs, + outputJsonSchema, declaredArgumentNames, requiredArgumentNames, annotations, consumer); } diff --git a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolEndpointLifecycleTest.java b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolEndpointLifecycleTest.java index e9b1aa8294555..0238416bc352b 100644 --- a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolEndpointLifecycleTest.java +++ b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolEndpointLifecycleTest.java @@ -383,6 +383,59 @@ public void configure() { assertThat(annotations.openWorldHint()).isTrue(); } + @Test + void testOutputSchemaToolRegisteredOnStart() throws Exception { + context.addRoutes(new RouteBuilder() { + public void configure() { + from("ai-tool:getWeatherStructured" + + "?tags=weather-structured" + + "&description=Get weather" + + "&outputSchema=classpath:output-schemas/weather-result.json") + .setBody(constant("{\"temperature\":21.5,\"unit\":\"celsius\"}")); + } + }); + + AiToolSpec spec = AiToolRegistry.getOrCreate(context).getToolsByTag("weather-structured").iterator().next(); + + assertThat(spec.getName()).isEqualTo("getWeatherStructured"); + + assertThat(spec.getOutputParameterDefs()).isEmpty(); + assertThat(spec.getOutputJsonSchema()).isNotNull().contains("\"temperature\""); + } + + @Test + void testOutputParameterToolRegisteredOnStart() throws Exception { + context.addRoutes(new RouteBuilder() { + public void configure() { + from("ai-tool:getTemperature" + + "?tags=weather-output" + + "&description=Get temperature" + + "&outputParameter.temperature=number" + + "&outputParameter.unit=string") + .setBody(constant("{\"temperature\":18,\"unit\":\"celsius\"}")); + } + }); + + AiToolSpec spec = AiToolRegistry.getOrCreate(context).getToolsByTag("weather-output").iterator().next(); + + assertThat(spec.getOutputParameterDefs()).containsKeys("temperature", "unit"); + assertThat(spec.getOutputJsonSchema()).contains("\"temperature\"").contains("\"unit\""); + } + + @Test + void testOutputSchemaAndOutputParametersAreMutuallyExclusive() { + assertThatThrownBy(() -> context.addRoutes(new RouteBuilder() { + public void configure() { + from("ai-tool:invalidOutput" + + "?tags=test" + + "&outputParameter.temperature=number" + + "&outputSchema={\"type\":\"object\"}") + .setBody(constant("invalid")); + } + })).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("mutually exclusive"); + } + @Test void testGetAllToolsReturnsAllPools() throws Exception { context.addRoutes(new RouteBuilder() { diff --git a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolExecutorTest.java b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolExecutorTest.java index 028be078bd319..34c71452100fc 100644 --- a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolExecutorTest.java +++ b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolExecutorTest.java @@ -85,6 +85,32 @@ public void configure() { List items = exchange.getMessage().getHeader("items", List.class); exchange.getMessage().setBody("order:" + customer.get("id") + ":" + items.size()); }); + + from("ai-tool:getWeather" + + "?tags=test" + + "&description=Get weather" + + "&outputSchema=classpath:output-schemas/weather-result.json") + .setBody(constant("{\"temperature\":21.5,\"unit\":\"celsius\"}")); + + from("ai-tool:getWeatherMap" + + "?tags=test" + + "&description=Get weather as map" + + "&outputParameter.temperature=number" + + "&outputParameter.unit=string") + .process(exchange -> exchange.getMessage().setBody( + Map.of("temperature", 18, "unit", "celsius"))); + + from("ai-tool:badStructuredOutput" + + "?tags=test" + + "&description=Returns invalid JSON" + + "&outputSchema=classpath:output-schemas/weather-result.json") + .setBody(constant("not-json")); + + from("ai-tool:nullStructuredOutput" + + "?tags=test" + + "&description=Returns null body with output schema" + + "&outputSchema=classpath:output-schemas/weather-result.json") + .setBody(constant((Object) null)); } }; } @@ -217,7 +243,7 @@ public void testExecuteReturnsArgumentErrorForMissingRequired() { @Test public void testExecuteReturnsExecutionErrorForNullConsumer() { - AiToolSpec spec = new AiToolSpec("ghostTool", "A tool with no consumer", Map.of(), null, null, null); + AiToolSpec spec = new AiToolSpec("ghostTool", "A tool with no consumer", Map.of(), null, Map.of(), null, null, null); Exchange exchange = new DefaultExchange(context); AiToolResult result = AiToolExecutor.execute(spec, null, exchange); @@ -235,7 +261,7 @@ public void testExecuteReturnsExecutionErrorForNullProcessor() { DefaultConsumer consumerWithNullProcessor = new DefaultConsumer(endpoint, null); AiToolSpec spec = new AiToolSpec( - "nullProc", "test", Map.of(), null, null, consumerWithNullProcessor); + "nullProc", "test", Map.of(), null, Map.of(), null, null, consumerWithNullProcessor); Exchange exchange = new DefaultExchange(context); AiToolResult result = AiToolExecutor.execute(spec, null, exchange); @@ -370,7 +396,9 @@ void testExecuteReturnsArgumentErrorWhenRequiredNameIsUndeclared() { "required": ["country"] } """; - AiToolSpec spec = new AiToolSpec("badRequired", "test", Map.of(), schema, null, findSpec("greetUser").getConsumer()); + AiToolSpec spec = new AiToolSpec( + "badRequired", "test", Map.of(), schema, Map.of(), null, null, + findSpec("greetUser").getConsumer()); Exchange exchange = new DefaultExchange(context); Map arguments = Map.of("country", "Paris"); @@ -381,6 +409,54 @@ void testExecuteReturnsArgumentErrorWhenRequiredNameIsUndeclared() { assertThat(((AiToolResult.ArgumentError) result).message()).contains("Missing required argument 'country'"); } + @Test + public void testExecuteReturnsStructuredContentFromJsonString() { + AiToolSpec spec = findSpec("getWeather"); + Exchange exchange = new DefaultExchange(context); + + AiToolResult result = AiToolExecutor.execute(spec, Map.of(), exchange); + + assertThat(result).isInstanceOf(AiToolResult.Success.class); + AiToolResult.Success success = (AiToolResult.Success) result; + assertThat(success.value()).contains("\"temperature\":21.5"); + assertThat(success.structuredContent()).isNotNull(); + } + + @Test + public void testExecuteReturnsStructuredContentFromMapBody() { + AiToolSpec spec = findSpec("getWeatherMap"); + Exchange exchange = new DefaultExchange(context); + + AiToolResult result = AiToolExecutor.execute(spec, Map.of(), exchange); + + assertThat(result).isInstanceOf(AiToolResult.Success.class); + AiToolResult.Success success = (AiToolResult.Success) result; + assertThat(success.structuredContent()).isInstanceOf(Map.class); + assertThat(success.value()).contains("\"temperature\":18"); + } + + @Test + public void testExecuteReturnsExecutionErrorForInvalidStructuredOutput() { + AiToolSpec spec = findSpec("badStructuredOutput"); + Exchange exchange = new DefaultExchange(context); + + AiToolResult result = AiToolExecutor.execute(spec, Map.of(), exchange); + + assertThat(result).isInstanceOf(AiToolResult.ExecutionError.class); + assertThat(((AiToolResult.ExecutionError) result).message()).contains("valid JSON"); + } + + @Test + public void testExecuteReturnsExecutionErrorForNullBodyWithOutputSchema() { + AiToolSpec spec = findSpec("nullStructuredOutput"); + Exchange exchange = new DefaultExchange(context); + + AiToolResult result = AiToolExecutor.execute(spec, Map.of(), exchange); + + assertThat(result).isInstanceOf(AiToolResult.ExecutionError.class); + assertThat(((AiToolResult.ExecutionError) result).message()).contains("must not be null"); + } + private AiToolSpec findSpec(String toolName) { return AiToolRegistry.getOrCreate(context).getToolsByTag("test").stream() .filter(s -> toolName.equals(s.getName())) diff --git a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolParameterHelperTest.java b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolParameterHelperTest.java index 81068d72ba906..aa4c8ddbea793 100644 --- a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolParameterHelperTest.java +++ b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolParameterHelperTest.java @@ -371,4 +371,76 @@ void testExtractTopLevelPropertyNamesRejectsInvalidProperties() { .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("properties must be a JSON object"); } + + @Test + void testValidateOutputSourceExclusive() { + assertThatThrownBy(() -> AiToolParameterHelper.validateOutputSourceExclusive( + Map.of("temperature", "number"), "{\"type\":\"object\"}")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("mutually exclusive"); + } + + @Test + void testResolveOutputSchemaFromInlineJson() { + String schema = AiToolParameterHelper.resolveOutputSchema(camelContext, """ + { + "type": "object", + "properties": { + "temperature": { "type": "number" } + } + } + """); + + assertThat(schema).contains("\"temperature\""); + } + + @Test + void testResolveOutputSchemaAllowsNonObjectRootType() { + String schema = AiToolParameterHelper.resolveOutputSchema(camelContext, "{\"type\":\"string\"}"); + + assertThat(schema).contains("\"type\":\"string\""); + } + + @Test + void testResolveOutputSchemaFromClasspath() { + String schema = AiToolParameterHelper.resolveOutputSchema(camelContext, + "classpath:output-schemas/weather-result.json"); + + assertThat(schema).contains("\"temperature\"").contains("\"unit\""); + } + + @Test + void testParseStructuredOutputFromJsonString() { + Object structured = AiToolParameterHelper.parseStructuredOutput(""" + {"temperature":21.5,"unit":"celsius"} + """); + + assertThat(structured).isInstanceOf(JsonObject.class); + assertThat(((JsonObject) structured).get("temperature").toString()).isEqualTo("21.5"); + assertThat(((JsonObject) structured).getString("unit")).isEqualTo("celsius"); + } + + @Test + void testParseStructuredOutputFromMap() { + Map body = Map.of("temperature", 21.5, "unit", "celsius"); + + Object structured = AiToolParameterHelper.parseStructuredOutput(body); + + assertThat(structured).isSameAs(body); + } + + @Test + void testParseStructuredOutputRejectsInvalidJsonString() { + assertThatThrownBy(() -> AiToolParameterHelper.parseStructuredOutput("{not-json")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("valid JSON"); + } + + @Test + void testStructuredContentToTextUsesOriginalJsonString() { + String json = "{\"temperature\":21.5,\"unit\":\"celsius\"}"; + JsonObject structured = (JsonObject) AiToolParameterHelper.parseStructuredOutput(json); + + assertThat(AiToolParameterHelper.structuredContentToText(structured, json)).isEqualTo(json); + } } diff --git a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryListenerTest.java b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryListenerTest.java index 5244083f452d0..25ea816592501 100644 --- a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryListenerTest.java +++ b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryListenerTest.java @@ -18,6 +18,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -136,7 +137,7 @@ void testRemovedListenerReceivesNoFurtherEvents() { } private static AiToolSpec spec(String name) { - return new AiToolSpec(name, name + " description", null, null, null, null); + return new AiToolSpec(name, name + " description", null, null, Map.of(), null, null, null); } private record Event(String type, String tag, AiToolSpec spec) { diff --git a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryTest.java b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryTest.java index d65a153a5c8f0..78fabd9617580 100644 --- a/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryTest.java +++ b/components/camel-ai/camel-ai-tool/src/test/java/org/apache/camel/component/ai/tool/AiToolRegistryTest.java @@ -43,7 +43,7 @@ public void setUp() { @Test public void testPutAndGetTool() { - AiToolSpec spec = new AiToolSpec("getTool", "A test tool", Map.of(), null, null, null); + AiToolSpec spec = new AiToolSpec("getTool", "A test tool", Map.of(), null, Map.of(), null, null, null); registry.put("weather", spec); @@ -56,7 +56,7 @@ public void testPutAndGetTool() { @Test public void testRemoveTool() { - AiToolSpec spec = new AiToolSpec("getTool", "A test tool", Map.of(), null, null, null); + AiToolSpec spec = new AiToolSpec("getTool", "A test tool", Map.of(), null, Map.of(), null, null, null); registry.put("weather", spec); assertThat(registry.getTools().get("weather")) @@ -71,8 +71,8 @@ public void testRemoveTool() { @Test public void testMultipleToolsWithSameTag() { - AiToolSpec spec1 = new AiToolSpec("tool1", "Tool 1", Map.of(), null, null, null); - AiToolSpec spec2 = new AiToolSpec("tool2", "Tool 2", Map.of(), null, null, null); + AiToolSpec spec1 = new AiToolSpec("tool1", "Tool 1", Map.of(), null, Map.of(), null, null, null); + AiToolSpec spec2 = new AiToolSpec("tool2", "Tool 2", Map.of(), null, Map.of(), null, null, null); registry.put("assistant", spec1); registry.put("assistant", spec2); @@ -84,8 +84,8 @@ public void testMultipleToolsWithSameTag() { @Test public void testRemoveOneOfMultipleTools() { - AiToolSpec spec1 = new AiToolSpec("tool1", "Tool 1", Map.of(), null, null, null); - AiToolSpec spec2 = new AiToolSpec("tool2", "Tool 2", Map.of(), null, null, null); + AiToolSpec spec1 = new AiToolSpec("tool1", "Tool 1", Map.of(), null, Map.of(), null, null, null); + AiToolSpec spec2 = new AiToolSpec("tool2", "Tool 2", Map.of(), null, Map.of(), null, null, null); registry.put("assistant", spec1); registry.put("assistant", spec2); @@ -101,8 +101,8 @@ public void testRemoveOneOfMultipleTools() { @Test public void testTagIsolation() { - AiToolSpec weatherSpec = new AiToolSpec("weather", "Weather", Map.of(), null, null, null); - AiToolSpec emailSpec = new AiToolSpec("email", "Email", Map.of(), null, null, null); + AiToolSpec weatherSpec = new AiToolSpec("weather", "Weather", Map.of(), null, Map.of(), null, null, null); + AiToolSpec emailSpec = new AiToolSpec("email", "Email", Map.of(), null, Map.of(), null, null, null); registry.put("weather", weatherSpec); registry.put("email", emailSpec); @@ -120,7 +120,7 @@ public void testTagIsolation() { @Test public void testRemoveFromNonExistentTag() { - AiToolSpec spec = new AiToolSpec("tool", "A tool", Map.of(), null, null, null); + AiToolSpec spec = new AiToolSpec("tool", "A tool", Map.of(), null, Map.of(), null, null, null); registry.remove("nonexistent", spec); assertThat(registry.getTools()) @@ -133,7 +133,7 @@ public void testRemoveFromNonExistentTag() { @Test public void testDefaultPoolPutAndRemove() { - AiToolSpec spec = new AiToolSpec("defaultTool", "Default", Map.of(), null, null, null); + AiToolSpec spec = new AiToolSpec("defaultTool", "Default", Map.of(), null, Map.of(), null, null, null); registry.putDefault(spec); assertThat(registry.getDefaultTools()) @@ -149,8 +149,8 @@ public void testDefaultPoolPutAndRemove() { @Test public void testGetToolsByTagIncludesDefaultPool() { - AiToolSpec taggedTool = new AiToolSpec("tagged", "Tagged", Map.of(), null, null, null); - AiToolSpec defaultTool = new AiToolSpec("default", "Default", Map.of(), null, null, null); + AiToolSpec taggedTool = new AiToolSpec("tagged", "Tagged", Map.of(), null, Map.of(), null, null, null); + AiToolSpec defaultTool = new AiToolSpec("default", "Default", Map.of(), null, Map.of(), null, null, null); registry.put("weather", taggedTool); registry.putDefault(defaultTool); @@ -164,7 +164,7 @@ public void testGetToolsByTagIncludesDefaultPool() { @Test public void testGetToolsByTagWithNoMatchReturnsDefaultOnly() { - AiToolSpec defaultTool = new AiToolSpec("default", "Default", Map.of(), null, null, null); + AiToolSpec defaultTool = new AiToolSpec("default", "Default", Map.of(), null, Map.of(), null, null, null); registry.putDefault(defaultTool); assertThat(registry.getToolsByTag("nonexistent")) @@ -192,7 +192,7 @@ public void testConcurrentPutRemoveAndGet() throws Exception { } for (int i = 0; i < opsPerThread; i++) { AiToolSpec spec = new AiToolSpec( - "tool-" + threadId + "-" + i, "desc", Map.of(), null, null, null); + "tool-" + threadId + "-" + i, "desc", Map.of(), null, Map.of(), null, null, null); registry.put("concurrent", spec); registry.getToolsByTag("concurrent"); registry.getAllTools(); @@ -215,8 +215,8 @@ public void testConcurrentPutRemoveAndGet() throws Exception { @Test public void testDuplicateToolNameUnderSameTagThrows() { - AiToolSpec spec1 = new AiToolSpec("sameName", "First", Map.of(), null, null, null); - AiToolSpec spec2 = new AiToolSpec("sameName", "Second", Map.of(), null, null, null); + AiToolSpec spec1 = new AiToolSpec("sameName", "First", Map.of(), null, Map.of(), null, null, null); + AiToolSpec spec2 = new AiToolSpec("sameName", "Second", Map.of(), null, Map.of(), null, null, null); registry.put("weather", spec1); @@ -233,8 +233,8 @@ public void testDuplicateToolNameUnderSameTagThrows() { @Test public void testDuplicateToolNameInDefaultPoolThrows() { - AiToolSpec spec1 = new AiToolSpec("sameName", "First", Map.of(), null, null, null); - AiToolSpec spec2 = new AiToolSpec("sameName", "Second", Map.of(), null, null, null); + AiToolSpec spec1 = new AiToolSpec("sameName", "First", Map.of(), null, Map.of(), null, null, null); + AiToolSpec spec2 = new AiToolSpec("sameName", "Second", Map.of(), null, Map.of(), null, null, null); registry.putDefault(spec1); @@ -251,8 +251,8 @@ public void testDuplicateToolNameInDefaultPoolThrows() { @Test public void testSameToolNameDifferentTagsIsAllowed() { - AiToolSpec spec1 = new AiToolSpec("sameName", "Weather version", Map.of(), null, null, null); - AiToolSpec spec2 = new AiToolSpec("sameName", "Email version", Map.of(), null, null, null); + AiToolSpec spec1 = new AiToolSpec("sameName", "Weather version", Map.of(), null, Map.of(), null, null, null); + AiToolSpec spec2 = new AiToolSpec("sameName", "Email version", Map.of(), null, Map.of(), null, null, null); registry.put("weather", spec1); registry.put("email", spec2); @@ -263,8 +263,8 @@ public void testSameToolNameDifferentTagsIsAllowed() { @Test public void testReRegisterAfterRemoveIsAllowed() { - AiToolSpec spec1 = new AiToolSpec("tool", "First", Map.of(), null, null, null); - AiToolSpec spec2 = new AiToolSpec("tool", "Second", Map.of(), null, null, null); + AiToolSpec spec1 = new AiToolSpec("tool", "First", Map.of(), null, Map.of(), null, null, null); + AiToolSpec spec2 = new AiToolSpec("tool", "Second", Map.of(), null, Map.of(), null, null, null); registry.put("weather", spec1); registry.remove("weather", spec1); @@ -277,9 +277,9 @@ public void testReRegisterAfterRemoveIsAllowed() { @Test public void testGetAllToolsMergesTaggedAndDefault() { - AiToolSpec tool1 = new AiToolSpec("tool1", "Tool 1", Map.of(), null, null, null); - AiToolSpec tool2 = new AiToolSpec("tool2", "Tool 2", Map.of(), null, null, null); - AiToolSpec defaultTool = new AiToolSpec("default", "Default", Map.of(), null, null, null); + AiToolSpec tool1 = new AiToolSpec("tool1", "Tool 1", Map.of(), null, Map.of(), null, null, null); + AiToolSpec tool2 = new AiToolSpec("tool2", "Tool 2", Map.of(), null, Map.of(), null, null, null); + AiToolSpec defaultTool = new AiToolSpec("default", "Default", Map.of(), null, Map.of(), null, null, null); registry.put("weather", tool1); registry.put("email", tool2); diff --git a/components/camel-ai/camel-ai-tool/src/test/resources/output-schemas/weather-result.json b/components/camel-ai/camel-ai-tool/src/test/resources/output-schemas/weather-result.json new file mode 100644 index 0000000000000..ccc90493aa044 --- /dev/null +++ b/components/camel-ai/camel-ai-tool/src/test/resources/output-schemas/weather-result.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "temperature": { "type": "number" }, + "unit": { "type": "string", "enum": ["celsius", "fahrenheit"] } + }, + "required": ["temperature", "unit"], + "additionalProperties": false +} diff --git a/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/AiToolSpecToLangChain4jTest.java b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/AiToolSpecToLangChain4jTest.java index e8904027d1ac3..8a1f382b4f45a 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/AiToolSpecToLangChain4jTest.java +++ b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/AiToolSpecToLangChain4jTest.java @@ -41,7 +41,7 @@ class AiToolSpecToLangChain4jTest { @Test void testBasicToolSpecConversion() { - AiToolSpec spec = new AiToolSpec("myTool", "A test tool", Map.of(), null, null, null); + AiToolSpec spec = new AiToolSpec("myTool", "A test tool", Map.of(), null, Map.of(), null, null, null); ToolSpecification result = AiToolSpecToLangChain4j.toToolSpecification(spec); @@ -56,7 +56,7 @@ void testStringParameter() { rawParams.put("city", "string"); Map defs = AiToolParameterHelper.parseParameterMetadata(rawParams); - AiToolSpec spec = new AiToolSpec("weather", "Get weather", defs, null, null, null); + AiToolSpec spec = new AiToolSpec("weather", "Get weather", defs, null, Map.of(), null, null, null); ToolSpecification result = AiToolSpecToLangChain4j.toToolSpecification(spec); @@ -71,7 +71,7 @@ void testIntegerParameter() { rawParams.put("count", "integer"); Map defs = AiToolParameterHelper.parseParameterMetadata(rawParams); - AiToolSpec spec = new AiToolSpec("counter", "Count items", defs, null, null, null); + AiToolSpec spec = new AiToolSpec("counter", "Count items", defs, null, Map.of(), null, null, null); ToolSpecification result = AiToolSpecToLangChain4j.toToolSpecification(spec); @@ -84,7 +84,7 @@ void testNumberParameter() { rawParams.put("price", "number"); Map defs = AiToolParameterHelper.parseParameterMetadata(rawParams); - AiToolSpec spec = new AiToolSpec("pricer", "Get price", defs, null, null, null); + AiToolSpec spec = new AiToolSpec("pricer", "Get price", defs, null, Map.of(), null, null, null); ToolSpecification result = AiToolSpecToLangChain4j.toToolSpecification(spec); @@ -97,7 +97,7 @@ void testBooleanParameter() { rawParams.put("active", "boolean"); Map defs = AiToolParameterHelper.parseParameterMetadata(rawParams); - AiToolSpec spec = new AiToolSpec("checker", "Check status", defs, null, null, null); + AiToolSpec spec = new AiToolSpec("checker", "Check status", defs, null, Map.of(), null, null, null); ToolSpecification result = AiToolSpecToLangChain4j.toToolSpecification(spec); @@ -111,7 +111,7 @@ void testEnumParameter() { rawParams.put("color.enum", "red,green,blue"); Map defs = AiToolParameterHelper.parseParameterMetadata(rawParams); - AiToolSpec spec = new AiToolSpec("colorPicker", "Pick a color", defs, null, null, null); + AiToolSpec spec = new AiToolSpec("colorPicker", "Pick a color", defs, null, Map.of(), null, null, null); ToolSpecification result = AiToolSpecToLangChain4j.toToolSpecification(spec); @@ -129,7 +129,7 @@ void testRequiredParameter() { rawParams.put("userId.required", "true"); Map defs = AiToolParameterHelper.parseParameterMetadata(rawParams); - AiToolSpec spec = new AiToolSpec("userLookup", "Look up user", defs, null, null, null); + AiToolSpec spec = new AiToolSpec("userLookup", "Look up user", defs, null, Map.of(), null, null, null); ToolSpecification result = AiToolSpecToLangChain4j.toToolSpecification(spec); @@ -147,7 +147,7 @@ void testMultipleParametersWithMixedTypes() { rawParams.put("active", "boolean"); Map defs = AiToolParameterHelper.parseParameterMetadata(rawParams); - AiToolSpec spec = new AiToolSpec("multiTool", "Multi-param tool", defs, null, null, null); + AiToolSpec spec = new AiToolSpec("multiTool", "Multi-param tool", defs, null, Map.of(), null, null, null); ToolSpecification result = AiToolSpecToLangChain4j.toToolSpecification(spec); @@ -189,7 +189,7 @@ void testRawArgSchemaConversion() { } """; - AiToolSpec spec = new AiToolSpec("createOrder", "Create order", Map.of(), schema, null, null); + AiToolSpec spec = new AiToolSpec("createOrder", "Create order", Map.of(), schema, Map.of(), null, null, null); ToolSpecification result = AiToolSpecToLangChain4j.toToolSpecification(spec); assertThat(result.parameters()).isNotNull(); @@ -199,7 +199,7 @@ void testRawArgSchemaConversion() { @Test void testEmptyParameters() { - AiToolSpec spec = new AiToolSpec("noParams", "Tool with no params", Map.of(), null, null, null); + AiToolSpec spec = new AiToolSpec("noParams", "Tool with no params", Map.of(), null, Map.of(), null, null, null); ToolSpecification result = AiToolSpecToLangChain4j.toToolSpecification(spec); diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java index 1c478aed0b1c0..70caf40f3570e 100644 --- a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java @@ -255,6 +255,11 @@ public McpToolCallHandler handler() { public AiToolAnnotations annotations() { return spec.getAnnotations(); } + + @Override + public String outputSchemaJson() { + return spec.getOutputJsonSchema(); + } }; } @@ -284,7 +289,7 @@ private McpToolCallResult execute(AiToolSpec spec, Map arguments return new McpToolCallResult(GENERIC_EXECUTION_ERROR, true); } if (result instanceof AiToolResult.Success success) { - return new McpToolCallResult(success.value(), false); + return new McpToolCallResult(success.value(), false, success.structuredContent()); } else if (result instanceof AiToolResult.ArgumentError error) { return new McpToolCallResult(error.message(), true); } else { diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerTool.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerTool.java index f348c6059d2a8..3ba36396f5abd 100644 --- a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerTool.java +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerTool.java @@ -60,4 +60,13 @@ public interface McpServerTool { default AiToolAnnotations annotations() { return null; } + + /** + * The tool output as a JSON Schema object string, or {@code null} when the tool declares no structured output. + * + * @since 4.22 + */ + default String outputSchemaJson() { + return null; + } } diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallResult.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallResult.java index 526236d5a11b0..a57bead1e0030 100644 --- a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallResult.java +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpToolCallResult.java @@ -20,10 +20,15 @@ * Result of an MCP tool invocation, pre-sanitized by the bridge: the text is safe to return to a remote MCP client and * never contains raw route exception messages. * - * @param text the tool output, or a safe error message when {@code isError} is true - * @param isError whether the invocation failed + * @param text the tool output, or a safe error message when {@code isError} is true + * @param isError whether the invocation failed + * @param structuredContent typed JSON output when the tool declares an output schema, otherwise {@code null} * - * @since 4.22 + * @since 4.22 */ -public record McpToolCallResult(String text, boolean isError) { +public record McpToolCallResult(String text, boolean isError, Object structuredContent) { + + public McpToolCallResult(String text, boolean isError) { + this(text, isError, null); + } } diff --git a/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerBridgeTest.java b/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerBridgeTest.java index 6d9db5c797938..26c927ba5f361 100644 --- a/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerBridgeTest.java +++ b/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerBridgeTest.java @@ -78,6 +78,11 @@ public void configure() { from("ai-tool:other_tool?tags=untrusted&description=Other tag") .setBody(constant("other")); + + from("ai-tool:getWeather?tags=crm&description=Get weather" + + "&outputSchema={\"type\":\"object\",\"properties\":{\"temperature\":{\"type\":\"number\"},\"unit\":{\"type\":\"string\"}},\"required\":[\"temperature\",\"unit\"]}") + .routeId("weather-route") + .setBody(constant("{\"temperature\":21.5,\"unit\":\"celsius\"}")); } }; } @@ -162,6 +167,22 @@ void testMultiTagToolRemovedOnceWhenRouteStops() throws Exception { assertThat(engine.removed()).containsOnlyOnce("send_email"); } + @Test + void testToolOutputSchemaPassedThroughBridge() { + McpServerTool tool = engine.tools().get("getWeather"); + + assertThat(tool.outputSchemaJson()).isNotNull().contains("\"temperature\""); + } + + @Test + void testCallToolReturnsStructuredContent() { + McpToolCallResult result = engine.tools().get("getWeather").handler().call(Map.of()); + + assertThat(result.isError()).isFalse(); + assertThat(result.text()).contains("\"temperature\":21.5"); + assertThat(result.structuredContent()).isNotNull(); + } + @Test void testNameCollisionIsRefused() throws Exception { McpServerTool published = engine.tools().get("query_db"); diff --git a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerEngine.java b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerEngine.java index a0435525722c1..63ed18dfc3554 100644 --- a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerEngine.java +++ b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerEngine.java @@ -149,10 +149,13 @@ public void toolAdded(McpServerTool tool) { .callHandler((exchange, request) -> { Map arguments = request.arguments() != null ? request.arguments() : Map.of(); McpToolCallResult result = tool.handler().call(arguments); - return McpSchema.CallToolResult.builder() + McpSchema.CallToolResult.Builder builder = McpSchema.CallToolResult.builder() .addTextContent(result.text()) - .isError(result.isError()) - .build(); + .isError(result.isError()); + if (result.structuredContent() != null) { + builder.structuredContent(result.structuredContent()); + } + return builder.build(); }) .build(); server.addTool(spec); @@ -177,6 +180,9 @@ private McpSchema.Tool buildMcpTool(McpServerTool tool) { String schema = tool.inputSchemaJson() != null ? tool.inputSchemaJson() : EMPTY_OBJECT_SCHEMA; McpSchema.Tool.Builder builder = McpSchema.Tool.builder(tool.name(), jsonMapper, schema) .description(tool.description()); + if (tool.outputSchemaJson() != null && !tool.outputSchemaJson().isBlank()) { + builder.outputSchema(jsonMapper, tool.outputSchemaJson()); + } applyAnnotations(builder, tool.annotations()); return builder.build(); } diff --git a/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerOutputSchemaTest.java b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerOutputSchemaTest.java new file mode 100644 index 0000000000000..95553c81d9a79 --- /dev/null +++ b/components/camel-ai/camel-mcp-server/src/test/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerOutputSchemaTest.java @@ -0,0 +1,138 @@ +/* + * 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.mcp.server.vertx; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.spec.McpSchema; +import org.apache.camel.CamelContext; +import org.apache.camel.component.mcp.server.McpServerInfo; +import org.apache.camel.component.mcp.server.McpServerTool; +import org.apache.camel.component.mcp.server.McpToolCallHandler; +import org.apache.camel.component.mcp.server.McpToolCallResult; +import org.apache.camel.component.platform.http.main.MainHttpServer; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.test.AvailablePortFinder; +import org.apache.camel.util.json.JsonObject; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class VertxMcpServerOutputSchemaTest { + + private static final String OUTPUT_SCHEMA = """ + { + "type": "object", + "properties": { + "temperature": { "type": "number" }, + "unit": { "type": "string" } + }, + "required": ["temperature", "unit"] + } + """; + + @Test + void testEnginePublishesOutputSchemaAndStructuredContent() throws Exception { + int port = AvailablePortFinder.getNextAvailable(); + CamelContext camelContext = new DefaultCamelContext(); + VertxMcpServerEngine engine = new VertxMcpServerEngine(); + McpSyncClient client = null; + try { + MainHttpServer main = new MainHttpServer(); + main.setCamelContext(camelContext); + main.setHost("0.0.0.0"); + main.setPort(port); + camelContext.addService(main); + camelContext.start(); + + engine.setCamelContext(camelContext); + engine.initialize(new McpServerInfo("output-schema", "1.0", "/mcp")); + engine.start(); + + JsonObject structured = new JsonObject(); + structured.put("temperature", 21.5); + structured.put("unit", "celsius"); + engine.toolAdded(tool("getWeather", "Get weather", OUTPUT_SCHEMA, + arguments -> new McpToolCallResult(structured.toJson(), false, structured))); + + client = McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + port).build()) + .requestTimeout(Duration.ofSeconds(10)) + .initializationTimeout(Duration.ofSeconds(10)) + .build(); + client.initialize(); + + List tools = client.listTools().tools(); + McpSchema.Tool weather = tools.stream().filter(t -> "getWeather".equals(t.name())).findFirst().orElseThrow(); + + assertThat(weather.outputSchema()).isNotNull(); + assertThat(weather.outputSchema()).containsKey("properties"); + + McpSchema.CallToolResult result = client.callTool(new McpSchema.CallToolRequest("getWeather", Map.of())); + + assertThat(result.isError()).isFalse(); + assertThat(result.structuredContent()).isNotNull(); + assertThat(result.structuredContent()).isInstanceOf(Map.class); + assertThat(((Map) result.structuredContent()).get("temperature")).isEqualTo(21.5); + } finally { + if (client != null) { + client.closeGracefully(); + } + engine.stop(); + camelContext.stop(); + } + } + + private static McpServerTool tool( + String name, String description, String outputSchemaJson, McpToolCallHandler handler) { + return new McpServerTool() { + @Override + public String name() { + return name; + } + + @Override + public String description() { + return description; + } + + @Override + public String inputSchemaJson() { + return null; + } + + @Override + public Map parameters() { + return Map.of(); + } + + @Override + public McpToolCallHandler handler() { + return handler; + } + + @Override + public String outputSchemaJson() { + return outputSchemaJson; + } + }; + } +} diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc index 1c4c03497e196..da5d4261420f2 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc @@ -31,6 +31,18 @@ TTL of 5 minutes (evict sessions with no POST/GET activity). Configure with `camel.server.mcp-session-keep-alive-interval` and `camel.server.mcp-session-idle-ttl`; set either option to `0` to disable that mechanism. +=== camel-ai-tool + +`AiToolResult.Success` now carries an optional `structuredContent` field when the tool declares an +output schema (`outputParameter.*` or `outputSchema`). The MCP bridge maps this to +`CallToolResult.structuredContent` and publishes `Tool.outputSchema` to MCP clients. LangChain4j +and Spring AI adapters continue to use the string `value()` only. + +SPI extensions in this release (before 4.22 ships): `McpToolCallResult` gains a third +`structuredContent` component; `McpServerTool` adds `outputSchemaJson()`; `AiToolSpec` constructor +accepts output schema fields. Custom `McpServerEngine` implementations should forward structured +content when present. + === camel-core ==== Property placeholders in toD and enrich dynamic endpoint URIs diff --git a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/AiToolComponentBuilderFactory.java b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/AiToolComponentBuilderFactory.java index 21ab68bca404e..f74b4a8b9a0d2 100644 --- a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/AiToolComponentBuilderFactory.java +++ b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/AiToolComponentBuilderFactory.java @@ -189,6 +189,47 @@ default AiToolComponentBuilder openWorldHint(java.lang.Boolean openWorldHint) { return this; } + /** + * Tool output schema fields. Format: outputParameter.NAME=TYPE, + * outputParameter.NAME.description=TEXT. Supported types: string, + * integer, number, boolean. Mutually exclusive with outputSchema. This + * is a multi-value option with prefix: outputParameter. + * + * The option is a: <code>java.util.Map&lt;java.lang.String, + * java.lang.String&gt;</code> type. + * + * Group: consumer + * + * @param outputParameters the value to set + * @return the dsl builder + */ + default AiToolComponentBuilder outputParameters(java.util.Map outputParameters) { + doSetProperty("outputParameters", outputParameters); + return this; + } + + /** + * Raw JSON Schema describing the tool's structured output. Supports + * inline JSON and resource references (classpath:, file:, resource:). + * Mutually exclusive with the outputParameter multi-value options. When + * declared, the route body is parsed as JSON and exposed as structured + * content to MCP clients. + * + * This option can also be loaded from an existing file, by prefixing + * with file: or classpath: followed by the location of the file. + * + * The option is a: <code>java.lang.String</code> type. + * + * Group: consumer + * + * @param outputSchema the value to set + * @return the dsl builder + */ + default AiToolComponentBuilder outputSchema(java.lang.String outputSchema) { + doSetProperty("outputSchema", outputSchema); + return this; + } + /** * Tool input parameters. Format: parameter.NAME=TYPE, * parameter.NAME.description=TEXT, parameter.NAME.required=true or @@ -310,6 +351,8 @@ protected boolean setPropertyOnComponent( case "destructiveHint": getOrCreateConfiguration((AiToolComponent) component).setDestructiveHint((java.lang.Boolean) value); return true; case "idempotentHint": getOrCreateConfiguration((AiToolComponent) component).setIdempotentHint((java.lang.Boolean) value); return true; case "openWorldHint": getOrCreateConfiguration((AiToolComponent) component).setOpenWorldHint((java.lang.Boolean) value); return true; + case "outputParameters": getOrCreateConfiguration((AiToolComponent) component).setOutputParameters((java.util.Map) value); return true; + case "outputSchema": getOrCreateConfiguration((AiToolComponent) component).setOutputSchema((java.lang.String) value); return true; case "parameters": getOrCreateConfiguration((AiToolComponent) component).setParameters((java.util.Map) value); return true; case "readOnlyHint": getOrCreateConfiguration((AiToolComponent) component).setReadOnlyHint((java.lang.Boolean) value); return true; case "tags": getOrCreateConfiguration((AiToolComponent) component).setTags((java.lang.String) value); return true; diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/AiToolEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/AiToolEndpointBuilderFactory.java index 74e7abf42bfdc..9d3e7cb45aaba 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/AiToolEndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/AiToolEndpointBuilderFactory.java @@ -182,6 +182,70 @@ default AiToolEndpointBuilder openWorldHint(String openWorldHint) { doSetProperty("openWorldHint", openWorldHint); return this; } + /** + * Tool output schema fields. Format: outputParameter.NAME=TYPE, + * outputParameter.NAME.description=TEXT. Supported types: string, + * integer, number, boolean. Mutually exclusive with outputSchema. This + * is a multi-value option with prefix: outputParameter. + * + * The option is a: java.util.Map<java.lang.String, + * java.lang.String> type. + * The option is multivalued, and you can use the + * outputParameters(String, Object) method to add a value (call the + * method multiple times to set more values). + * + * Group: consumer + * + * @param key the option key + * @param value the option value + * @return the dsl builder + */ + default AiToolEndpointBuilder outputParameters(String key, Object value) { + doSetMultiValueProperty("outputParameters", "outputParameter." + key, value); + return this; + } + /** + * Tool output schema fields. Format: outputParameter.NAME=TYPE, + * outputParameter.NAME.description=TEXT. Supported types: string, + * integer, number, boolean. Mutually exclusive with outputSchema. This + * is a multi-value option with prefix: outputParameter. + * + * The option is a: java.util.Map<java.lang.String, + * java.lang.String> type. + * The option is multivalued, and you can use the + * outputParameters(String, Object) method to add a value (call the + * method multiple times to set more values). + * + * Group: consumer + * + * @param values the values + * @return the dsl builder + */ + default AiToolEndpointBuilder outputParameters(Map values) { + doSetMultiValueProperties("outputParameters", "outputParameter.", values); + return this; + } + /** + * Raw JSON Schema describing the tool's structured output. Supports + * inline JSON and resource references (classpath:, file:, resource:). + * Mutually exclusive with the outputParameter multi-value options. When + * declared, the route body is parsed as JSON and exposed as structured + * content to MCP clients. + * + * This option can also be loaded from an existing file, by prefixing + * with file: or classpath: followed by the location of the file. + * + * The option is a: java.lang.String type. + * + * Group: consumer + * + * @param outputSchema the value to set + * @return the dsl builder + */ + default AiToolEndpointBuilder outputSchema(String outputSchema) { + doSetProperty("outputSchema", outputSchema); + return this; + } /** * Tool input parameters. Format: parameter.NAME=TYPE, * parameter.NAME.description=TEXT, parameter.NAME.required=true or