Add HTTP server call block and retire MCP agent

This commit is contained in:
Lucio Lelii 2026-03-13 12:08:58 +01:00
parent 8c11dbb191
commit 9e475ca09b
14 changed files with 460 additions and 499 deletions

View File

@ -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<HTTPServerCallBlockType> {
@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<HTTPServerCallBlockType> 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
}
}

View File

@ -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);
}
}
}
}

View File

@ -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<MCPAgentBlockType> {
@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<MCPAgentBlockType> getBlockType() {
return MCPAgentBlockType.class;
}
public static MCPAgentBlockConfiguration empty() {
MCPAgentBlockConfiguration configuration = new MCPAgentBlockConfiguration();
configuration.name = MCPAgentBlockType.TYPE;
configuration.maxIterations = 3;
return configuration;
}
}

View File

@ -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 "";
}

View File

@ -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<HTTPServerCallBlockType, HTTPServerCallBlockConfiguration> {
public static final String OUTPUT_NAME = "response";
private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\$\\{\\{(.*?)}}");
@Autowired
private HTTPServerCallBlockType blockType;
@Override
public Block<HTTPServerCallBlockType> create(HTTPServerCallBlockConfiguration configuration) {
return Block.<HTTPServerCallBlockType>builder()
.inputs(retrieveInputs(configuration))
.output(IODescriptor.of(OUTPUT_NAME, IOType.TEXT))
.specificConfiguration(configuration)
.type(blockType)
.build();
}
@Override
public Block<HTTPServerCallBlockType> createEmpty() {
return create(HTTPServerCallBlockConfiguration.empty());
}
@Override
public Class<HTTPServerCallBlockType> getBlockType() {
return HTTPServerCallBlockType.class;
}
private List<IODescriptor> retrieveInputs(HTTPServerCallBlockConfiguration configuration) {
Set<String> 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<String> names, String template) {
if (template == null || template.isBlank()) {
return;
}
Matcher matcher = PLACEHOLDER_PATTERN.matcher(template);
while (matcher.find()) {
names.add(matcher.group(1));
}
}
}

View File

@ -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<MCPAgentBlockType, MCPAgentBlockConfiguration> {
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<MCPAgentBlockType> create(MCPAgentBlockConfiguration configuration) {
List<IODescriptor> inputs = retrieveInputs(configuration);
return Block.<MCPAgentBlockType>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<MCPAgentBlockType> createEmpty() {
return create(MCPAgentBlockConfiguration.empty());
}
@Override
public Class<MCPAgentBlockType> getBlockType() {
return MCPAgentBlockType.class;
}
private List<IODescriptor> retrieveInputs(MCPAgentBlockConfiguration configuration) {
Set<String> 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<String> names) {
if (value == null || value.isBlank()) {
return;
}
Matcher matcher = PLACEHOLDER_PATTERN.matcher(value);
while (matcher.find()) {
names.add(matcher.group(1));
}
}
}

View File

@ -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<? extends BlockConfiguration<?>> getBlockConfigurationClass() {
return MCPAgentBlockConfiguration.class;
return HTTPServerCallBlockConfiguration.class;
}
}

View File

@ -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<String, RequirementAccumulator> 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<String, RequirementAccumulator> 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) {

View File

@ -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<HTTPServerCallBlockType> {
@Autowired
private HTTPServerCallService httpServerCallService;
@Override
public Map<String, Object> execute(Block<HTTPServerCallBlockType> block, List<Input> inputs,
Map<String, Object> 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<HTTPServerCallBlockType> getBlockType() {
return HTTPServerCallBlockType.class;
}
private String resolveTemplate(String template, List<Input> 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;
}
}

View File

@ -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<MCPAgentBlockType> {
@Autowired
private Map<String, LLMProvider> llmProviders;
@Override
public Map<String, Object> execute(Block<MCPAgentBlockType> block, List<Input> inputs,
Map<String, Object> 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<MCPAgentBlockType> 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<Input> 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<Input> inputs, LLMProvider llmProvider,
LLMDescriptor llmDescriptor, Map<String, Object> 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<Input> inputs, JsonNode tools,
String scratchpad, LLMProvider llmProvider, LLMDescriptor llmDescriptor, Map<String, Object> 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<String> parseCommandArgs(String rawArgs) {
if (!StringUtils.hasText(rawArgs)) {
return List.of();
}
try {
JsonNode node = ObjectMapperHolder.mapper.readTree(rawArgs);
if (node.isArray()) {
List<String> 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<Input> 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) {
}
}

View File

@ -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;
}
}

View File

@ -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<MCPAgentBlockType, MCPAgentBlockConfiguration> 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<MCPAgentBlockType> block = factory.create(config);
assertNotNull(block);
assertNotNull(block.getSpecificConfiguration());
assertNotNull(block.getInputs());
assertNotNull(block.getOutputs());
}
@Test
void createMCPBridgeBlock() {
BlockFactory<MCPBridgeBlockType, MCPBridgeBlockConfiguration> factory = mcpBridgeBlockFactory;
@ -118,4 +89,25 @@ public class BlockTest {
assertNotNull(((MCPBridgeBlockConfiguration) block.getSpecificConfiguration()).getMcpServers());
}
@Test
void createHTTPServerCallBlock() {
BlockFactory<HTTPServerCallBlockType, HTTPServerCallBlockConfiguration> 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<HTTPServerCallBlockType> block = factory.create(config);
assertNotNull(block);
assertNotNull(block.getSpecificConfiguration());
assertNotNull(block.getInputs());
assertNotNull(block.getOutputs());
}
}

View File

@ -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<MCPAgentBlockType> 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<MCPAgentBlockType> 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<HTTPServerCallBlockType> 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<HTTPServerCallBlockType> 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<MCPBridgeBlockType> block = blocksController.getExampleForType(MCPBridgeBlockType.TYPE);

View File

@ -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<HTTPServerCallBlockType> 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);