diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/HTTPServerCallBlockConfiguration.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/HTTPServerCallBlockConfiguration.java new file mode 100644 index 0000000..77265ff --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/HTTPServerCallBlockConfiguration.java @@ -0,0 +1,115 @@ +package it.cnr.isti.workflow.manager.blocks.configurations; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import it.cnr.isti.workflow.manager.blocks.configurations.annotations.LongText; +import it.cnr.isti.workflow.manager.blocks.configurations.annotations.Structural; +import it.cnr.isti.workflow.manager.blocks.configurations.annotations.UiDependency; +import it.cnr.isti.workflow.manager.blocks.types.HTTPServerCallBlockType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +@Getter +@EqualsAndHashCode(callSuper = true) +public class HTTPServerCallBlockConfiguration extends BlockConfiguration { + + @NotBlank + @JsonProperty(required = true) + private String url; + + @NotNull + @JsonProperty(required = true) + private HTTPMethod method; + + @Structural + @UiDependency(field = "method", equalsAny = { "POST", "PUT" }) + @LongText( + placeholder = "Optional HTTP request body.", + tip = "Use ${{}} placeholders to inject workflow variables into the URL or request body.", + acceptVariableAsPlaceholder = true) + @JsonProperty(required = false) + private String requestBody; + + @NotNull + @JsonProperty(required = true) + private Boolean requiresAuthorization; + + @NotNull + @UiDependency(field = "requiresAuthorization", equals = "true", group = "authorization") + @JsonProperty(required = true) + private HTTPAuthorizationType authorizationType; + + @UiDependency(field = "authorizationType", equals = "API_KEY", group = "authorization") + @JsonProperty(required = false) + private String authorizationHeaderName; + + @Builder + public HTTPServerCallBlockConfiguration(@NonNull String name, + @NonNull String url, + @NonNull HTTPMethod method, + String requestBody, + @NonNull Boolean requiresAuthorization, + @NonNull HTTPAuthorizationType authorizationType, + String authorizationHeaderName) { + super(name); + this.url = url; + this.method = method; + this.requestBody = requestBody; + this.requiresAuthorization = requiresAuthorization; + this.authorizationType = authorizationType; + this.authorizationHeaderName = authorizationHeaderName; + } + + @Override + public Class getBlockType() { + return HTTPServerCallBlockType.class; + } + + public static HTTPServerCallBlockConfiguration empty() { + HTTPServerCallBlockConfiguration configuration = new HTTPServerCallBlockConfiguration(); + configuration.name = HTTPServerCallBlockType.TYPE; + configuration.method = HTTPMethod.GET; + configuration.requiresAuthorization = false; + configuration.authorizationType = HTTPAuthorizationType.API_KEY; + configuration.authorizationHeaderName = "X-API-Key"; + return configuration; + } + + public String authorizationKey(String blockId) { + return "HTTPServerCall::" + blockId + "::" + authorizationFieldName(); + } + + public String authorizationFieldName() { + return switch (authorizationType) { + case API_KEY -> "apiKey"; + case CREDENTIALS -> "credentials"; + }; + } + + public String authorizationDescription() { + return switch (authorizationType) { + case API_KEY -> "HTTP API key value for " + url + + (authorizationHeaderName == null || authorizationHeaderName.isBlank() + ? "" + : " using header " + authorizationHeaderName); + case CREDENTIALS -> "HTTP Basic credentials for " + url + " in the format username:password"; + }; + } + + public enum HTTPAuthorizationType { + API_KEY, + CREDENTIALS + } + + public enum HTTPMethod { + GET, + POST, + PUT + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/JsonSchemaProducer.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/JsonSchemaProducer.java index 29c0093..f52bece 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/JsonSchemaProducer.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/JsonSchemaProducer.java @@ -411,7 +411,15 @@ public class JsonSchemaProducer { } ObjectNode visibleWhen = propertySchema.putObject("x-ui-visible-when"); visibleWhen.put("field", dependency.field()); - visibleWhen.put("equals", dependency.equals()); + if (!dependency.equals().isBlank()) { + visibleWhen.put("equals", dependency.equals()); + } + if (dependency.equalsAny().length > 0) { + ArrayNode equalsAny = visibleWhen.putArray("in"); + for (String value : dependency.equalsAny()) { + equalsAny.add(value); + } + } } } diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/MCPAgentBlockConfiguration.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/MCPAgentBlockConfiguration.java deleted file mode 100644 index e2c0df9..0000000 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/MCPAgentBlockConfiguration.java +++ /dev/null @@ -1,93 +0,0 @@ -package it.cnr.isti.workflow.manager.blocks.configurations; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import it.cnr.isti.workflow.manager.blocks.configurations.annotations.LongText; -import it.cnr.isti.workflow.manager.blocks.configurations.annotations.Structural; -import it.cnr.isti.workflow.manager.blocks.types.MCPAgentBlockType; -import it.cnr.isti.workflow.manager.llms.LLMDescriptor; -import jakarta.validation.Valid; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import lombok.Builder; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.NonNull; - -@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) -@Getter -@EqualsAndHashCode(callSuper = true) -public class MCPAgentBlockConfiguration extends BlockConfiguration { - - @NotNull - @Valid - @JsonProperty(required = true) - private LLMDescriptor llmDescriptor; - - @NotBlank - @Structural - @LongText( - placeholder = "Define the system-level behavior of the MCP agent", - tip = "Use this to constrain style, goals, and safety behavior for the agent.") - @JsonProperty(required = true) - private String systemPrompt; - - @NotBlank - @Structural - @LongText( - placeholder = "Describe the task the agent must solve. Use ${{}} for workflow variables.", - tip = "Inputs are derived automatically from placeholders in the task prompt.", - acceptVariableAsPlaceholder = true) - @JsonProperty(required = true) - private String taskPrompt; - - @NotBlank - @LongText( - placeholder = "Describe available MCP servers and tools as JSON or structured text", - tip = "This MVP passes MCP capabilities as context to the model; it does not execute tools directly.") - @JsonProperty(required = true) - private String mcpContext; - - @LongText( - placeholder = "Executable command used to start an MCP server over stdio, for example npx", - tip = "Optional. When present, the MCP agent will try to start and use a real MCP server.") - private String mcpServerCommand; - - @LongText( - placeholder = "Command arguments as JSON array or space separated text, for example [\"-y\",\"@modelcontextprotocol/server-filesystem\",\"/tmp\"]", - tip = "Optional. Used together with mcpServerCommand for real MCP execution.") - private String mcpServerArgs; - - @NotNull - @Min(1) - @JsonProperty(required = true) - private Integer maxIterations; - - @Builder - public MCPAgentBlockConfiguration(@NonNull String name, @NonNull LLMDescriptor llmDescriptor, - @NonNull String systemPrompt, @NonNull String taskPrompt, @NonNull String mcpContext, - String mcpServerCommand, String mcpServerArgs, @NonNull Integer maxIterations) { - super(name); - this.llmDescriptor = llmDescriptor; - this.systemPrompt = systemPrompt; - this.taskPrompt = taskPrompt; - this.mcpContext = mcpContext; - this.mcpServerCommand = mcpServerCommand; - this.mcpServerArgs = mcpServerArgs; - this.maxIterations = maxIterations; - } - - @Override - public Class getBlockType() { - return MCPAgentBlockType.class; - } - - public static MCPAgentBlockConfiguration empty() { - MCPAgentBlockConfiguration configuration = new MCPAgentBlockConfiguration(); - configuration.name = MCPAgentBlockType.TYPE; - configuration.maxIterations = 3; - return configuration; - } -} diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/annotations/UiDependency.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/annotations/UiDependency.java index 2b56a47..d881b8f 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/annotations/UiDependency.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/annotations/UiDependency.java @@ -10,7 +10,9 @@ import java.lang.annotation.Target; public @interface UiDependency { String field(); - String equals(); + String equals() default ""; + + String[] equalsAny() default {}; String group() default ""; } diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/HTTPServerCallBlockFactory.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/HTTPServerCallBlockFactory.java new file mode 100644 index 0000000..6c9ca15 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/HTTPServerCallBlockFactory.java @@ -0,0 +1,63 @@ +package it.cnr.isti.workflow.manager.blocks.factories; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import it.cnr.isti.workflow.manager.blocks.Block; +import it.cnr.isti.workflow.manager.blocks.configurations.HTTPServerCallBlockConfiguration; +import it.cnr.isti.workflow.manager.blocks.types.HTTPServerCallBlockType; +import it.cnr.isti.workflow.manager.ios.IODescriptor; +import it.cnr.isti.workflow.manager.ios.IOType; + +@Component +public class HTTPServerCallBlockFactory implements BlockFactory { + + public static final String OUTPUT_NAME = "response"; + private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\$\\{\\{(.*?)}}"); + + @Autowired + private HTTPServerCallBlockType blockType; + + @Override + public Block create(HTTPServerCallBlockConfiguration configuration) { + return Block.builder() + .inputs(retrieveInputs(configuration)) + .output(IODescriptor.of(OUTPUT_NAME, IOType.TEXT)) + .specificConfiguration(configuration) + .type(blockType) + .build(); + } + + @Override + public Block createEmpty() { + return create(HTTPServerCallBlockConfiguration.empty()); + } + + @Override + public Class getBlockType() { + return HTTPServerCallBlockType.class; + } + + private List retrieveInputs(HTTPServerCallBlockConfiguration configuration) { + Set names = new LinkedHashSet<>(); + collectPlaceholders(names, configuration.getUrl()); + collectPlaceholders(names, configuration.getRequestBody()); + return names.stream().map(name -> IODescriptor.of(name, IOType.TEXT)).toList(); + } + + private void collectPlaceholders(Set names, String template) { + if (template == null || template.isBlank()) { + return; + } + Matcher matcher = PLACEHOLDER_PATTERN.matcher(template); + while (matcher.find()) { + names.add(matcher.group(1)); + } + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/MCPAgentBlockFactory.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/MCPAgentBlockFactory.java deleted file mode 100644 index 73ec4a8..0000000 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/factories/MCPAgentBlockFactory.java +++ /dev/null @@ -1,70 +0,0 @@ -package it.cnr.isti.workflow.manager.blocks.factories; - -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; - -import it.cnr.isti.workflow.manager.blocks.Block; -import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentBlockConfiguration; -import it.cnr.isti.workflow.manager.blocks.types.MCPAgentBlockType; -import it.cnr.isti.workflow.manager.ios.IODescriptor; -import it.cnr.isti.workflow.manager.ios.IOType; - -@Component -public class MCPAgentBlockFactory implements BlockFactory { - - public static final String RESPONSE_OUTPUT = "response"; - public static final String TRACE_OUTPUT = "trace"; - public static final String INPUT_NAME = "task"; - private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\$\\{\\{(.*?)}}"); - - @Autowired - private MCPAgentBlockType blockType; - - @Override - public Block create(MCPAgentBlockConfiguration configuration) { - List inputs = retrieveInputs(configuration); - return Block.builder() - .inputs(inputs) - .output(IODescriptor.of(RESPONSE_OUTPUT, IOType.TEXT)) - .output(IODescriptor.of(TRACE_OUTPUT, IOType.TEXT)) - .specificConfiguration(configuration) - .type(blockType) - .build(); - } - - @Override - public Block createEmpty() { - return create(MCPAgentBlockConfiguration.empty()); - } - - @Override - public Class getBlockType() { - return MCPAgentBlockType.class; - } - - private List retrieveInputs(MCPAgentBlockConfiguration configuration) { - Set names = new LinkedHashSet<>(); - collectPlaceholders(configuration.getSystemPrompt(), names); - collectPlaceholders(configuration.getTaskPrompt(), names); - if (names.isEmpty()) { - return List.of(IODescriptor.of(INPUT_NAME, IOType.TEXT)); - } - return names.stream().map(name -> IODescriptor.of(name, IOType.TEXT)).toList(); - } - - private void collectPlaceholders(String value, Set names) { - if (value == null || value.isBlank()) { - return; - } - Matcher matcher = PLACEHOLDER_PATTERN.matcher(value); - while (matcher.find()) { - names.add(matcher.group(1)); - } - } -} diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/types/MCPAgentBlockType.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/types/HTTPServerCallBlockType.java similarity index 56% rename from src/main/java/it/cnr/isti/workflow/manager/blocks/types/MCPAgentBlockType.java rename to src/main/java/it/cnr/isti/workflow/manager/blocks/types/HTTPServerCallBlockType.java index ead19f3..898f2c3 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/types/MCPAgentBlockType.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/types/HTTPServerCallBlockType.java @@ -3,12 +3,12 @@ package it.cnr.isti.workflow.manager.blocks.types; import org.springframework.stereotype.Component; import it.cnr.isti.workflow.manager.blocks.configurations.BlockConfiguration; -import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentBlockConfiguration; +import it.cnr.isti.workflow.manager.blocks.configurations.HTTPServerCallBlockConfiguration; -@Component(MCPAgentBlockType.TYPE) -public class MCPAgentBlockType implements BlockType { +@Component(HTTPServerCallBlockType.TYPE) +public class HTTPServerCallBlockType implements BlockType { - public static final String TYPE = "MCPAgentBlock"; + public static final String TYPE = "HTTPServerCall"; @Override public String getName() { @@ -17,7 +17,7 @@ public class MCPAgentBlockType implements BlockType { @Override public String getDescription() { - return "An agent-style block that uses an LLM with declared MCP tool context"; + return "Calls an external HTTP endpoint using a configured URL, optional request body, and optional runtime authorization"; } @Override @@ -32,6 +32,6 @@ public class MCPAgentBlockType implements BlockType { @Override public Class> getBlockConfigurationClass() { - return MCPAgentBlockConfiguration.class; + return HTTPServerCallBlockConfiguration.class; } } diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java index 092ed7c..26a97db 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java @@ -11,9 +11,9 @@ import org.springframework.stereotype.Service; import it.cnr.isti.workflow.manager.blocks.Block; import it.cnr.isti.workflow.manager.blocks.configurations.ConditionalBlockConfiguration; +import it.cnr.isti.workflow.manager.blocks.configurations.HTTPServerCallBlockConfiguration; import it.cnr.isti.workflow.manager.blocks.configurations.HumanInteractiveBlockConfiguration; import it.cnr.isti.workflow.manager.blocks.configurations.LLMBlockConfiguration; -import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentBlockConfiguration; import it.cnr.isti.workflow.manager.flows.model.Flow; import it.cnr.isti.workflow.manager.flows.model.FlowData; import it.cnr.isti.workflow.manager.flows.validation.FlowExecutionValidator; @@ -108,6 +108,7 @@ public class ExecutionsService { Map requirements = new LinkedHashMap<>(); for (Block block : flow.getBlocks()) { collectRequirement(requirements, block, resolveDescriptor(block), block == null ? null : block.getName()); + collectHttpRequirement(requirements, block); } return requirements.values().stream() .map(RequirementAccumulator::toRequirement) @@ -129,9 +130,6 @@ public class ExecutionsService { && conditionalConfiguration.isUseLlm()) { return conditionalConfiguration.getLlmDescriptor(); } - if (configuration instanceof MCPAgentBlockConfiguration mcpAgentConfiguration) { - return mcpAgentConfiguration.getLlmDescriptor(); - } return null; } @@ -151,6 +149,20 @@ public class ExecutionsService { .addStepReference(block == null ? null : block.getId(), stepName); } + private void collectHttpRequirement(Map requirements, Block block) { + if (block == null || !(block.getSpecificConfiguration() instanceof HTTPServerCallBlockConfiguration configuration)) { + return; + } + if (!Boolean.TRUE.equals(configuration.getRequiresAuthorization())) { + return; + } + String key = configuration.authorizationKey(block.getId()); + requirements.computeIfAbsent(key, + ignored -> new RequirementAccumulator(key, "HTTP", configuration.authorizationFieldName(), + configuration.authorizationDescription())) + .addStepReference(block.getId(), block.getName()); + } + private LLMProvider resolveProvider(String providerName) { LLMProvider provider = llmProviders.get(providerName); if (provider != null) { diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/HTTPServerCallExecutor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/HTTPServerCallExecutor.java new file mode 100644 index 0000000..7f261cc --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/HTTPServerCallExecutor.java @@ -0,0 +1,59 @@ +package it.cnr.isti.workflow.manager.executions.executors; + +import java.util.List; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import it.cnr.isti.workflow.manager.blocks.Block; +import it.cnr.isti.workflow.manager.blocks.configurations.HTTPServerCallBlockConfiguration; +import it.cnr.isti.workflow.manager.blocks.factories.HTTPServerCallBlockFactory; +import it.cnr.isti.workflow.manager.blocks.types.HTTPServerCallBlockType; +import it.cnr.isti.workflow.manager.executions.steps.Input; +import it.cnr.isti.workflow.manager.http.HTTPServerCallService; + +@Component +public class HTTPServerCallExecutor implements BlockExecutor { + + @Autowired + private HTTPServerCallService httpServerCallService; + + @Override + public Map execute(Block block, List inputs, + Map authorizations) { + HTTPServerCallBlockConfiguration config = (HTTPServerCallBlockConfiguration) block.getSpecificConfiguration(); + String resolvedUrl = resolveTemplate(config.getUrl(), inputs); + String resolvedRequestBody = resolveTemplate(config.getRequestBody(), inputs); + String authorizationValue = null; + if (Boolean.TRUE.equals(config.getRequiresAuthorization())) { + String authorizationKey = config.authorizationKey(block.getId()); + Object rawAuthorization = authorizations.get(authorizationKey); + authorizationValue = rawAuthorization == null ? null : rawAuthorization.toString(); + if (!StringUtils.hasText(authorizationValue)) { + throw new IllegalArgumentException("Missing authorization for HTTP server call block " + block.getName()); + } + } + String response = httpServerCallService.execute(config, resolvedUrl, resolvedRequestBody, authorizationValue); + return Map.of(HTTPServerCallBlockFactory.OUTPUT_NAME, response); + } + + @Override + public Class getBlockType() { + return HTTPServerCallBlockType.class; + } + + private String resolveTemplate(String template, List inputs) { + if (!StringUtils.hasText(template)) { + return template; + } + String resolved = template; + for (Input input : inputs) { + Object value = input.getValue(); + resolved = resolved.replace("${{" + input.getDescriptor().getName() + "}}", + value == null ? "null" : value.toString()); + } + return resolved; + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/MCPAgentExecutor.java b/src/main/java/it/cnr/isti/workflow/manager/executions/executors/MCPAgentExecutor.java deleted file mode 100644 index 5184d05..0000000 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/executors/MCPAgentExecutor.java +++ /dev/null @@ -1,252 +0,0 @@ -package it.cnr.isti.workflow.manager.executions.executors; - -import java.util.List; -import java.util.Map; -import java.util.ArrayList; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ObjectNode; - -import it.cnr.isti.workflow.manager.app.ObjectMapperHolder; -import it.cnr.isti.workflow.manager.blocks.Block; -import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentBlockConfiguration; -import it.cnr.isti.workflow.manager.blocks.factories.MCPAgentBlockFactory; -import it.cnr.isti.workflow.manager.blocks.types.MCPAgentBlockType; -import it.cnr.isti.workflow.manager.executions.steps.Input; -import it.cnr.isti.workflow.manager.llms.LLMDescriptor; -import it.cnr.isti.workflow.manager.llms.providers.LLMProvider; -import it.cnr.isti.workflow.manager.mcp.McpStdioClient; - -@Component -public class MCPAgentExecutor implements BlockExecutor { - - @Autowired - private Map llmProviders; - - @Override - public Map execute(Block block, List inputs, - Map authorizations) { - MCPAgentBlockConfiguration config = (MCPAgentBlockConfiguration) block.getSpecificConfiguration(); - LLMDescriptor llmDescriptor = config.getLlmDescriptor(); - LLMProvider llmProvider = resolveProvider(llmDescriptor.provider()); - - String authKey = llmProvider.authorizationKey(); - if (llmProvider.requiresAuthorization() - && (!authorizations.containsKey(authKey) || !StringUtils.hasText(authorizations.get(authKey).toString()))) { - throw new IllegalArgumentException("Missing authorization for provider: " + llmDescriptor.provider()); - } - - String response; - String trace; - if (StringUtils.hasText(config.getMcpServerCommand())) { - AgentExecutionResult result = executeWithMcp(config, inputs, llmProvider, llmDescriptor, authorizations, authKey); - response = result.response(); - trace = result.trace(); - } else { - String prompt = buildPrompt(config, inputs); - response = llmProvider.requiresAuthorization() - ? llmProvider.generate(llmDescriptor.model(), prompt, authorizations.get(authKey).toString()) - : llmProvider.generate(llmDescriptor.model(), prompt); - trace = "MCP agent executed in single-shot mode with declared MCP context and maxIterations=" - + config.getMaxIterations(); - } - - return Map.of( - MCPAgentBlockFactory.RESPONSE_OUTPUT, response, - MCPAgentBlockFactory.TRACE_OUTPUT, trace); - } - - @Override - public Class getBlockType() { - return MCPAgentBlockType.class; - } - - private LLMProvider resolveProvider(String providerName) { - LLMProvider llmProvider = llmProviders.get(providerName); - if (llmProvider != null) { - return llmProvider; - } - return llmProviders.values().stream() - .filter(provider -> provider.getName().equals(providerName)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Provider not found: " + providerName)); - } - - private String buildPrompt(MCPAgentBlockConfiguration config, List inputs) { - String systemPrompt = resolvePlaceholders(config.getSystemPrompt(), inputs); - String taskPrompt = resolvePlaceholders(config.getTaskPrompt(), inputs); - return """ - You are operating as an MCP-enabled workflow agent. - This implementation is single-shot: you cannot actually call tools, but you must reason using the declared MCP context. - - System prompt: - %s - - Declared MCP context: - %s - - Max iterations budget: - %s - - Task: - %s - - Return the best final answer for the task. - Do not describe internal chain-of-thought. - """.formatted(systemPrompt, config.getMcpContext(), config.getMaxIterations(), taskPrompt); - } - - private AgentExecutionResult executeWithMcp(MCPAgentBlockConfiguration config, List inputs, LLMProvider llmProvider, - LLMDescriptor llmDescriptor, Map authorizations, String authKey) { - try (McpStdioClient client = new McpStdioClient(config.getMcpServerCommand(), parseCommandArgs(config.getMcpServerArgs()))) { - client.initialize(); - JsonNode tools = client.listTools(); - StringBuilder trace = new StringBuilder(); - trace.append("MCP stdio server started: ").append(config.getMcpServerCommand()).append("\n"); - String scratchpad = ""; - for (int iteration = 1; iteration <= config.getMaxIterations(); iteration++) { - AgentDecision decision = decideNextStep(config, inputs, tools, scratchpad, llmProvider, llmDescriptor, - authorizations, authKey, iteration); - if ("final".equalsIgnoreCase(decision.action())) { - trace.append("Iteration ").append(iteration).append(": final answer produced\n"); - return new AgentExecutionResult(decision.answer(), trace.toString().trim()); - } - if (!"tool".equalsIgnoreCase(decision.action())) { - throw new IllegalArgumentException("Unsupported MCP agent action: " + decision.action()); - } - - JsonNode toolResult = client.callTool(decision.toolName(), - decision.arguments() == null ? ObjectMapperHolder.mapper.createObjectNode() : decision.arguments()); - String toolResultJson = ObjectMapperHolder.mapper.writeValueAsString(toolResult); - trace.append("Iteration ").append(iteration).append(": called tool ") - .append(decision.toolName()).append(" with result ").append(toolResultJson).append("\n"); - scratchpad = scratchpad + "\nTool " + decision.toolName() + " result: " + toolResultJson; - } - - String finalPrompt = """ - You are an MCP-enabled workflow agent. - Provide the final answer using the information already gathered. - - System prompt: - %s - - Task: - %s - - Scratchpad: - %s - """.formatted( - resolvePlaceholders(config.getSystemPrompt(), inputs), - resolvePlaceholders(config.getTaskPrompt(), inputs), - scratchpad); - - String answer = llmProvider.requiresAuthorization() - ? llmProvider.generate(llmDescriptor.model(), finalPrompt, authorizations.get(authKey).toString()) - : llmProvider.generate(llmDescriptor.model(), finalPrompt); - trace.append("Reached max iterations and requested final answer"); - return new AgentExecutionResult(answer, trace.toString().trim()); - } catch (Exception e) { - throw new IllegalArgumentException("MCP agent execution failed: " + e.getMessage(), e); - } - } - - private AgentDecision decideNextStep(MCPAgentBlockConfiguration config, List inputs, JsonNode tools, - String scratchpad, LLMProvider llmProvider, LLMDescriptor llmDescriptor, Map authorizations, - String authKey, int iteration) throws Exception { - String prompt = """ - You are an MCP-enabled workflow agent. - You may either produce a final answer or request one MCP tool call. - Return only JSON with one of these shapes: - {"action":"final","answer":"..."} - {"action":"tool","toolName":"...","arguments":{...}} - - System prompt: - %s - - Task: - %s - - Available tools: - %s - - Scratchpad: - %s - - Current iteration: - %s - """.formatted( - resolvePlaceholders(config.getSystemPrompt(), inputs), - resolvePlaceholders(config.getTaskPrompt(), inputs), - ObjectMapperHolder.mapper.writeValueAsString(tools), - scratchpad == null || scratchpad.isBlank() ? "(empty)" : scratchpad, - iteration); - - String rawResponse = llmProvider.requiresAuthorization() - ? llmProvider.generate(llmDescriptor.model(), prompt, authorizations.get(authKey).toString()) - : llmProvider.generate(llmDescriptor.model(), prompt); - String json = extractJsonObject(rawResponse); - JsonNode node = ObjectMapperHolder.mapper.readTree(json); - String action = node.path("action").asText(); - String answer = node.path("answer").asText(null); - String toolName = node.path("toolName").asText(null); - JsonNode arguments = node.path("arguments"); - if (arguments.isMissingNode() || arguments.isNull()) { - arguments = ObjectMapperHolder.mapper.createObjectNode(); - } - return new AgentDecision(action, toolName, arguments, answer); - } - - private List parseCommandArgs(String rawArgs) { - if (!StringUtils.hasText(rawArgs)) { - return List.of(); - } - try { - JsonNode node = ObjectMapperHolder.mapper.readTree(rawArgs); - if (node.isArray()) { - List args = new ArrayList<>(); - for (JsonNode item : node) { - args.add(item.asText()); - } - return args; - } - } catch (Exception ignored) { - } - return List.of(rawArgs.trim().split("\\s+")); - } - - private String resolvePlaceholders(String template, List inputs) { - String resolved = template; - if (resolved == null) { - return null; - } - for (Input input : inputs) { - Object value = input.getValue(); - resolved = resolved.replace("${{" + input.getDescriptor().getName() + "}}", - value == null ? "null" : value.toString()); - } - return resolved; - } - - private String extractJsonObject(String rawResponse) { - if (!StringUtils.hasText(rawResponse)) { - throw new IllegalArgumentException("Empty LLM response for MCP agent decision"); - } - String trimmed = rawResponse.trim(); - int start = trimmed.indexOf('{'); - int end = trimmed.lastIndexOf('}'); - if (start < 0 || end < start) { - throw new IllegalArgumentException("No JSON object found in MCP agent decision"); - } - return trimmed.substring(start, end + 1); - } - - private record AgentDecision(String action, String toolName, JsonNode arguments, String answer) { - } - - private record AgentExecutionResult(String response, String trace) { - } -} diff --git a/src/main/java/it/cnr/isti/workflow/manager/http/HTTPServerCallService.java b/src/main/java/it/cnr/isti/workflow/manager/http/HTTPServerCallService.java new file mode 100644 index 0000000..7b91d5f --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/http/HTTPServerCallService.java @@ -0,0 +1,81 @@ +package it.cnr.isti.workflow.manager.http; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Objects; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.client.WebClient; + +import it.cnr.isti.workflow.manager.blocks.configurations.HTTPServerCallBlockConfiguration; + +@Component +public class HTTPServerCallService { + + private final WebClient.Builder webClientBuilder; + + public HTTPServerCallService(WebClient.Builder webClientBuilder) { + this.webClientBuilder = Objects.requireNonNull(webClientBuilder, "webClientBuilder cannot be null"); + } + + public String execute(HTTPServerCallBlockConfiguration configuration, String resolvedUrl, String resolvedRequestBody, + String authorizationValue) { + WebClient.RequestHeadersSpec requestSpec; + WebClient webClient = webClientBuilder.build(); + + requestSpec = switch (configuration.getMethod()) { + case GET -> { + WebClient.RequestHeadersUriSpec getSpec = webClient.get(); + WebClient.RequestHeadersSpec headersSpec = getSpec.uri(resolvedUrl); + applyAuthorization(configuration, headersSpec, authorizationValue); + yield headersSpec; + } + case POST -> { + WebClient.RequestBodySpec bodySpec = webClient.post() + .uri(resolvedUrl) + .contentType(MediaType.TEXT_PLAIN); + applyAuthorization(configuration, bodySpec, authorizationValue); + yield bodySpec.bodyValue(resolvedRequestBody == null ? "" : resolvedRequestBody); + } + case PUT -> { + WebClient.RequestBodySpec bodySpec = webClient.put() + .uri(resolvedUrl) + .contentType(MediaType.TEXT_PLAIN); + applyAuthorization(configuration, bodySpec, authorizationValue); + yield bodySpec.bodyValue(resolvedRequestBody == null ? "" : resolvedRequestBody); + } + }; + + return requestSpec.retrieve() + .bodyToMono(String.class) + .block(); + } + + private void applyAuthorization(HTTPServerCallBlockConfiguration configuration, + WebClient.RequestHeadersSpec requestSpec, String authorizationValue) { + if (!Boolean.TRUE.equals(configuration.getRequiresAuthorization())) { + return; + } + if (!StringUtils.hasText(authorizationValue)) { + throw new IllegalArgumentException("Missing authorization for HTTP server call block"); + } + switch (configuration.getAuthorizationType()) { + case API_KEY -> requestSpec.header(resolveApiKeyHeader(configuration), authorizationValue); + case CREDENTIALS -> requestSpec.header(HttpHeaders.AUTHORIZATION, toBasicAuthorization(authorizationValue)); + } + } + + private String resolveApiKeyHeader(HTTPServerCallBlockConfiguration configuration) { + return StringUtils.hasText(configuration.getAuthorizationHeaderName()) + ? configuration.getAuthorizationHeaderName() + : "X-API-Key"; + } + + private String toBasicAuthorization(String credentials) { + String encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + return "Basic " + encoded; + } +} diff --git a/src/test/java/it/cnr/isti/workflow/manager/blocks/BlockTest.java b/src/test/java/it/cnr/isti/workflow/manager/blocks/BlockTest.java index 6bb6ea6..190131a 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/blocks/BlockTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/blocks/BlockTest.java @@ -10,16 +10,16 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.TestPropertySource; import it.cnr.isti.workflow.manager.blocks.configurations.LLMBlockConfiguration; +import it.cnr.isti.workflow.manager.blocks.configurations.HTTPServerCallBlockConfiguration; import it.cnr.isti.workflow.manager.blocks.configurations.MCPBridgeBlockConfiguration; -import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentBlockConfiguration; import it.cnr.isti.workflow.manager.app.ObjectMapperHolder; import it.cnr.isti.workflow.manager.blocks.factories.BlockFactory; +import it.cnr.isti.workflow.manager.blocks.factories.HTTPServerCallBlockFactory; import it.cnr.isti.workflow.manager.blocks.factories.LLMBlockFactory; import it.cnr.isti.workflow.manager.blocks.factories.MCPBridgeBlockFactory; -import it.cnr.isti.workflow.manager.blocks.factories.MCPAgentBlockFactory; +import it.cnr.isti.workflow.manager.blocks.types.HTTPServerCallBlockType; import it.cnr.isti.workflow.manager.blocks.types.LLMBlockType; import it.cnr.isti.workflow.manager.blocks.types.MCPBridgeBlockType; -import it.cnr.isti.workflow.manager.blocks.types.MCPAgentBlockType; import it.cnr.isti.workflow.manager.llms.LLMDescriptor; @SpringBootTest @@ -33,10 +33,10 @@ public class BlockTest { LLMBlockType llmBlockType; @Autowired - MCPAgentBlockFactory mcpAgentBlockFactory; + MCPBridgeBlockFactory mcpBridgeBlockFactory; @Autowired - MCPBridgeBlockFactory mcpBridgeBlockFactory; + HTTPServerCallBlockFactory httpServerCallBlockFactory; @Test @@ -62,35 +62,6 @@ public class BlockTest { } - @Test - void createMCPAgentBlock() { - BlockFactory factory = mcpAgentBlockFactory; - - LLMDescriptor llmDescriptor = LLMDescriptor.builder() - .provider("testProvider") - .model("testModel") - .build(); - - MCPAgentBlockConfiguration config = MCPAgentBlockConfiguration.builder() - .name("agent") - .llmDescriptor(llmDescriptor) - .systemPrompt("You are an expert recruiter.") - .taskPrompt("Analyze candidate ${{candidate}} for role ${{role}}") - .mcpContext(""" - { - "servers": ["cv-parser", "scoring-engine"] - } - """) - .maxIterations(3) - .build(); - - Block block = factory.create(config); - assertNotNull(block); - assertNotNull(block.getSpecificConfiguration()); - assertNotNull(block.getInputs()); - assertNotNull(block.getOutputs()); - } - @Test void createMCPBridgeBlock() { BlockFactory factory = mcpBridgeBlockFactory; @@ -118,4 +89,25 @@ public class BlockTest { assertNotNull(((MCPBridgeBlockConfiguration) block.getSpecificConfiguration()).getMcpServers()); } + @Test + void createHTTPServerCallBlock() { + BlockFactory factory = httpServerCallBlockFactory; + + HTTPServerCallBlockConfiguration config = HTTPServerCallBlockConfiguration.builder() + .name("Remote call") + .url("https://example.com/users/${{userId}}") + .method(HTTPServerCallBlockConfiguration.HTTPMethod.POST) + .requestBody("{\"message\":\"Hello ${{name}}\"}") + .requiresAuthorization(true) + .authorizationType(HTTPServerCallBlockConfiguration.HTTPAuthorizationType.API_KEY) + .authorizationHeaderName("X-API-Key") + .build(); + + Block block = factory.create(config); + assertNotNull(block); + assertNotNull(block.getSpecificConfiguration()); + assertNotNull(block.getInputs()); + assertNotNull(block.getOutputs()); + } + } diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java index b40c4c8..afc4870 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java @@ -17,13 +17,13 @@ import com.fasterxml.jackson.databind.JsonNode; import it.cnr.isti.workflow.manager.blocks.Block; import it.cnr.isti.workflow.manager.blocks.types.HumanInteractionBlockType; +import it.cnr.isti.workflow.manager.blocks.configurations.HTTPServerCallBlockConfiguration; import it.cnr.isti.workflow.manager.blocks.configurations.LLMBlockConfiguration; import it.cnr.isti.workflow.manager.blocks.configurations.MCPBridgeBlockConfiguration; -import it.cnr.isti.workflow.manager.blocks.configurations.MCPAgentBlockConfiguration; import it.cnr.isti.workflow.manager.controllers.BlocksController.BlockConfigurationDescriptor; +import it.cnr.isti.workflow.manager.blocks.types.HTTPServerCallBlockType; import it.cnr.isti.workflow.manager.blocks.types.LLMBlockType; import it.cnr.isti.workflow.manager.blocks.types.MCPBridgeBlockType; -import it.cnr.isti.workflow.manager.blocks.types.MCPAgentBlockType; import it.cnr.isti.workflow.manager.llms.LLMDescriptor; import it.cnr.isti.workflow.manager.app.ObjectMapperHolder; @@ -138,42 +138,6 @@ public class BlocksControllerTest { assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("output"))); } - @Test - public void createMCPAgentBlock() { - MCPAgentBlockConfiguration config = MCPAgentBlockConfiguration.builder() - .name("CV agent") - .llmDescriptor(LLMDescriptor.builder() - .provider("testProvider") - .model("testModel") - .build()) - .systemPrompt("You are an HR assistant.") - .taskPrompt("Analyze CV for ${{candidate}} against role ${{role}}") - .mcpContext("servers: cv-parser, recruiter-db") - .maxIterations(4) - .build(); - - Block block = blocksController.create(config); - assertNotNull(block); - assertEquals(MCPAgentBlockType.TYPE, block.getType().getName()); - assertTrue(block.getInputs().stream().anyMatch(input -> input.getName().equals("candidate"))); - assertTrue(block.getInputs().stream().anyMatch(input -> input.getName().equals("role"))); - assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("response"))); - assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("trace"))); - } - - @Test - public void getMCPAgentExampleForType() { - Block block = blocksController.getExampleForType(MCPAgentBlockType.TYPE); - - assertNotNull(block); - assertEquals(MCPAgentBlockType.TYPE, block.getType().getName()); - assertEquals(MCPAgentBlockType.TYPE, block.getName()); - assertNotNull(block.getSpecificConfiguration()); - assertTrue(block.getInputs().stream().anyMatch(input -> input.getName().equals("task"))); - assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("response"))); - assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("trace"))); - } - @Test public void createMCPBridgeBlock() { MCPBridgeBlockConfiguration config = MCPBridgeBlockConfiguration.builder() @@ -194,6 +158,59 @@ public class BlocksControllerTest { assertEquals("filesystem", ((MCPBridgeBlockConfiguration) block.getSpecificConfiguration()).getMcpServers().getFirst().serverName()); } + @Test + public void createHTTPServerCallBlock() { + HTTPServerCallBlockConfiguration config = HTTPServerCallBlockConfiguration.builder() + .name("External API") + .url("https://example.com/users/${{userId}}") + .method(HTTPServerCallBlockConfiguration.HTTPMethod.POST) + .requestBody("{\"message\":\"Hello ${{name}}\"}") + .requiresAuthorization(true) + .authorizationType(HTTPServerCallBlockConfiguration.HTTPAuthorizationType.CREDENTIALS) + .build(); + + Block block = blocksController.create(config); + assertNotNull(block); + assertEquals(HTTPServerCallBlockType.TYPE, block.getType().getName()); + assertTrue(block.getInputs().stream().anyMatch(input -> input.getName().equals("userId"))); + assertTrue(block.getInputs().stream().anyMatch(input -> input.getName().equals("name"))); + assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("response"))); + } + + @Test + public void getHTTPServerCallExampleForType() { + Block block = blocksController.getExampleForType(HTTPServerCallBlockType.TYPE); + + assertNotNull(block); + assertEquals(HTTPServerCallBlockType.TYPE, block.getType().getName()); + assertEquals(HTTPServerCallBlockType.TYPE, block.getName()); + assertNotNull(block.getSpecificConfiguration()); + assertTrue(block.getInputs().isEmpty()); + assertTrue(block.getOutputs().stream().anyMatch(output -> output.getName().equals("response"))); + } + + @Test + public void httpServerCallSchemaContainsAuthorizationHints() { + BlockConfigurationDescriptor descriptor = blocksController + .getConfigurationDescriptorForType(HTTPServerCallBlockType.TYPE); + + JsonNode schema = (JsonNode) descriptor.schema(); + JsonNode method = schema.path("properties").path("method"); + JsonNode requestBody = schema.path("properties").path("requestBody"); + JsonNode authType = schema.path("properties").path("authorizationType"); + JsonNode authHeader = schema.path("properties").path("authorizationHeaderName"); + + assertTrue(method.has("enum")); + assertEquals("textarea", requestBody.path("x-ui-widget").asText()); + assertTrue(requestBody.path("x-ui-structural").asBoolean()); + assertEquals("method", requestBody.path("x-ui-visible-when").path("field").asText()); + assertTrue(requestBody.path("x-ui-visible-when").path("in").isArray()); + assertTrue(requestBody.path("x-ui-visible-when").path("in").toString().contains("POST")); + assertTrue(requestBody.path("x-ui-visible-when").path("in").toString().contains("PUT")); + assertEquals("requiresAuthorization", authType.path("x-ui-visible-when").path("field").asText()); + assertEquals("authorizationType", authHeader.path("x-ui-visible-when").path("field").asText()); + } + @Test public void getMCPBridgeExampleForType() { Block block = blocksController.getExampleForType(MCPBridgeBlockType.TYPE); diff --git a/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java b/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java index a1c27b9..4cdfe94 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java @@ -24,8 +24,11 @@ import it.cnr.isti.workflow.manager.flows.model.FlowData; import it.cnr.isti.workflow.manager.flows.FlowTestCreator; import it.cnr.isti.workflow.manager.flows.model.Flow; import it.cnr.isti.workflow.manager.blocks.Block; +import it.cnr.isti.workflow.manager.blocks.configurations.HTTPServerCallBlockConfiguration; import it.cnr.isti.workflow.manager.blocks.configurations.LLMBlockConfiguration; +import it.cnr.isti.workflow.manager.blocks.factories.HTTPServerCallBlockFactory; import it.cnr.isti.workflow.manager.blocks.factories.LLMBlockFactory; +import it.cnr.isti.workflow.manager.blocks.types.HTTPServerCallBlockType; import it.cnr.isti.workflow.manager.blocks.types.LLMBlockType; import it.cnr.isti.workflow.manager.llms.LLMDescriptor; import it.cnr.isti.workflow.manager.llms.providers.LLMProvider; @@ -68,6 +71,9 @@ public class ExecutionTest { @Autowired LLMBlockFactory llmBlockFactory; + @Autowired + HTTPServerCallBlockFactory httpServerCallBlockFactory; + LLMDescriptor llmBrick = LLMDescriptor.builder() .provider("testProvider") .model("testModel") @@ -156,6 +162,27 @@ public class ExecutionTest { .allMatch(step -> step.getStatus() == StepStatus.READY)); } + @Test + public void createHttpServerCallExecutionRequiresAuthorization() { + Block block = httpServerCallBlockFactory.create(HTTPServerCallBlockConfiguration.builder() + .name("Protected endpoint") + .url("https://example.com/ping") + .method(HTTPServerCallBlockConfiguration.HTTPMethod.GET) + .requiresAuthorization(true) + .authorizationType(HTTPServerCallBlockConfiguration.HTTPAuthorizationType.API_KEY) + .authorizationHeaderName("X-API-Key") + .build()); + FlowData flow = FlowData.builder().block(block).build(); + + ExecutionObject execObject = executionsService.createExecution("Protected HTTP Call", flow); + + assertEquals(ExecutionStatus.CREATED, execObject.getContext().getStatus()); + assertEquals(1, execObject.getRequiredAuthorizations().size()); + assertEquals("apiKey", execObject.getRequiredAuthorizations().getFirst().fieldName()); + assertTrue(execObject.getMissingAuthorizationKeys().contains( + ((HTTPServerCallBlockConfiguration) block.getSpecificConfiguration()).authorizationKey(block.getId()))); + } + private ExecutionObject createExecutionAndSetInputInternally() { Flow flow = flowTestCreator.createFlowWithConnection(llmBrick); ExecutionObject execObject = executionsService.createExecution(flow);