From 0633dbdc6565d65e53a9903b0f0c0b3f908fcf3a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Nov 2025 05:03:59 +0000 Subject: [PATCH 1/7] Initial plan From 98e73c64d22c9736fb0f69f4c6e4ce7a01158594 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Nov 2025 05:25:30 +0000 Subject: [PATCH 2/7] Add JSON-RPC server for IDE plugin integration Implements a lightweight JSON-RPC 2.0 server accessible via 'fcli util rpc-server start'. This provides programmatic access to fcli functionality for IDE plugins. Key features: - Custom JSON-RPC 2.0 implementation (no external dependencies, GraalVM compatible) - RPC methods: fcli.execute, fcli.listCommands, fcli.version, rpc.listMethods - Pagination support for record-producing commands - Structured JSON responses for IDE integration Reuses patterns from MCP server implementation. Co-authored-by: rsenden <8635138+rsenden@users.noreply.github.com> --- .../cli/util/_main/cli/cmd/UtilCommands.java | 2 + .../rpc_server/cli/cmd/RPCServerCommands.java | 30 +++ .../cli/cmd/RPCServerStartCommand.java | 50 ++++ .../helper/rpc/IRpcMethodHandler.java | 33 +++ .../rpc_server/helper/rpc/JsonRpcError.java | 76 ++++++ .../rpc_server/helper/rpc/JsonRpcRequest.java | 43 +++ .../helper/rpc/JsonRpcResponse.java | 64 +++++ .../rpc_server/helper/rpc/JsonRpcServer.java | 216 +++++++++++++++ .../helper/rpc/RpcMethodException.java | 64 +++++ .../rpc/RpcMethodHandlerFcliExecute.java | 152 +++++++++++ .../rpc/RpcMethodHandlerFcliListCommands.java | 127 +++++++++ .../rpc/RpcMethodHandlerFcliVersion.java | 50 ++++ .../rpc/RpcMethodHandlerListMethods.java | 66 +++++ .../cli/util/i18n/UtilMessages.properties | 42 +++ .../rpc_server/unit/JsonRpcServerTest.java | 247 ++++++++++++++++++ 15 files changed, 1262 insertions(+) create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerCommands.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/IRpcMethodHandler.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcError.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcRequest.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcResponse.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodException.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecute.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliListCommands.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliVersion.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerListMethods.java create mode 100644 fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_main/cli/cmd/UtilCommands.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_main/cli/cmd/UtilCommands.java index 00d29dbe6e..e53ef42430 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_main/cli/cmd/UtilCommands.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_main/cli/cmd/UtilCommands.java @@ -17,6 +17,7 @@ import com.fortify.cli.util.autocomplete.cli.cmd.AutoCompleteCommands; import com.fortify.cli.util.crypto.cli.cmd.CryptoCommands; import com.fortify.cli.util.mcp_server.cli.cmd.MCPServerCommands; +import com.fortify.cli.util.rpc_server.cli.cmd.RPCServerCommands; import com.fortify.cli.util.sample_data.cli.cmd.SampleDataCommands; import com.fortify.cli.util.state.cli.cmd.StateCommands; import com.fortify.cli.util.variable.cli.cmd.VariableCommands; @@ -31,6 +32,7 @@ AutoCompleteCommands.class, CryptoCommands.class, MCPServerCommands.class, + RPCServerCommands.class, SampleDataCommands.class, StateCommands.class, VariableCommands.class diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerCommands.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerCommands.java new file mode 100644 index 0000000000..0e1f194677 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerCommands.java @@ -0,0 +1,30 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.cli.cmd; + +import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; + +import picocli.CommandLine.Command; + +/** + * Container command for JSON-RPC server commands. + * + * @author Ruud Senden + */ +@Command( + name = "rpc-server", + subcommands = { + RPCServerStartCommand.class + } +) +public class RPCServerCommands extends AbstractContainerCommand {} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java new file mode 100644 index 0000000000..f53ef6ace5 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java @@ -0,0 +1,50 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.cli.cmd; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.common.cli.cmd.AbstractRunnableCommand; +import com.fortify.cli.common.mcp.MCPExclude; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.util.rpc_server.helper.rpc.JsonRpcServer; + +import lombok.extern.slf4j.Slf4j; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +/** + * Command to start the fcli JSON-RPC server for IDE plugin integration. + * The server listens on stdin/stdout for JSON-RPC 2.0 requests. + * + * @author Ruud Senden + */ +@Command(name = OutputHelperMixins.Start.CMD_NAME) +@MCPExclude +@Slf4j +public class RPCServerStartCommand extends AbstractRunnableCommand { + @Option(names = {"--threads", "-t"}, defaultValue = "4") + private int threads; + + @Override + public Integer call() throws Exception { + log.info("Starting JSON-RPC server with {} threads", threads); + + var objectMapper = new ObjectMapper(); + var server = new JsonRpcServer(objectMapper, threads); + + // Start the server on stdin/stdout + server.start(System.in, System.out); + + return 0; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/IRpcMethodHandler.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/IRpcMethodHandler.java new file mode 100644 index 0000000000..95fc1bb156 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/IRpcMethodHandler.java @@ -0,0 +1,33 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Interface for JSON-RPC method handlers. Each handler is responsible for + * executing a specific RPC method and returning the result. + * + * @author Ruud Senden + */ +@FunctionalInterface +public interface IRpcMethodHandler { + /** + * Execute the RPC method with the given parameters. + * + * @param params the method parameters (may be null) + * @return the result as a JsonNode, or null if no result + * @throws RpcMethodException if the method execution fails + */ + JsonNode execute(JsonNode params) throws RpcMethodException; +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcError.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcError.java new file mode 100644 index 0000000000..e126beedd7 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcError.java @@ -0,0 +1,76 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.databind.JsonNode; +import com.formkiq.graalvm.annotations.Reflectable; + +/** + * JSON-RPC 2.0 error object. Per specification: + * - code: Integer indicating the error type + * - message: String providing a short description of the error + * - data: Optional value containing additional information about the error + * + * Standard error codes: + * -32700: Parse error + * -32600: Invalid Request + * -32601: Method not found + * -32602: Invalid params + * -32603: Internal error + * -32000 to -32099: Server error (reserved for implementation-defined errors) + * + * @author Ruud Senden + */ +@Reflectable +@JsonInclude(Include.NON_NULL) +public record JsonRpcError( + int code, + String message, + JsonNode data +) { + public static final int PARSE_ERROR = -32700; + public static final int INVALID_REQUEST = -32600; + public static final int METHOD_NOT_FOUND = -32601; + public static final int INVALID_PARAMS = -32602; + public static final int INTERNAL_ERROR = -32603; + public static final int SERVER_ERROR = -32000; + + public static JsonRpcError parseError() { + return new JsonRpcError(PARSE_ERROR, "Parse error", null); + } + + public static JsonRpcError invalidRequest() { + return new JsonRpcError(INVALID_REQUEST, "Invalid Request", null); + } + + public static JsonRpcError methodNotFound(String method) { + return new JsonRpcError(METHOD_NOT_FOUND, "Method not found: " + method, null); + } + + public static JsonRpcError invalidParams(String details) { + return new JsonRpcError(INVALID_PARAMS, "Invalid params: " + details, null); + } + + public static JsonRpcError internalError(String details) { + return new JsonRpcError(INTERNAL_ERROR, "Internal error: " + details, null); + } + + public static JsonRpcError serverError(int code, String message, JsonNode data) { + if (code > SERVER_ERROR || code < SERVER_ERROR - 99) { + code = SERVER_ERROR; + } + return new JsonRpcError(code, message, data); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcRequest.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcRequest.java new file mode 100644 index 0000000000..e90ca5955b --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcRequest.java @@ -0,0 +1,43 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.JsonNode; +import com.formkiq.graalvm.annotations.Reflectable; + +/** + * JSON-RPC 2.0 request object. Per specification: + * - jsonrpc: MUST be "2.0" + * - method: String containing the name of the method to be invoked + * - params: Optional structured value holding parameter values + * - id: An identifier established by the client (can be string, number, or null for notifications) + * + * @author Ruud Senden + */ +@Reflectable +@JsonIgnoreProperties(ignoreUnknown = true) +public record JsonRpcRequest( + String jsonrpc, + String method, + JsonNode params, + JsonNode id +) { + public boolean isNotification() { + return id == null || id.isNull(); + } + + public boolean isValid() { + return "2.0".equals(jsonrpc) && method != null && !method.isBlank(); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcResponse.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcResponse.java new file mode 100644 index 0000000000..9d6ed0727a --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcResponse.java @@ -0,0 +1,64 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.databind.JsonNode; +import com.formkiq.graalvm.annotations.Reflectable; + +/** + * JSON-RPC 2.0 response object. Per specification: + * - jsonrpc: MUST be "2.0" + * - result: Required on success. Value determined by method invocation. + * - error: Required on error. Error object describing the error. + * - id: MUST be same as request id, or null if id couldn't be determined + * + * @author Ruud Senden + */ +@Reflectable +@JsonInclude(Include.NON_NULL) +public record JsonRpcResponse( + String jsonrpc, + JsonNode result, + JsonRpcError error, + JsonNode id +) { + public static JsonRpcResponse success(JsonNode id, JsonNode result) { + return new JsonRpcResponse("2.0", result, null, id); + } + + public static JsonRpcResponse error(JsonNode id, JsonRpcError error) { + return new JsonRpcResponse("2.0", null, error, id); + } + + public static JsonRpcResponse parseError() { + return error(null, JsonRpcError.parseError()); + } + + public static JsonRpcResponse invalidRequest(JsonNode id) { + return error(id, JsonRpcError.invalidRequest()); + } + + public static JsonRpcResponse methodNotFound(JsonNode id, String method) { + return error(id, JsonRpcError.methodNotFound(method)); + } + + public static JsonRpcResponse invalidParams(JsonNode id, String message) { + return error(id, JsonRpcError.invalidParams(message)); + } + + public static JsonRpcResponse internalError(JsonNode id, String message) { + return error(id, JsonRpcError.internalError(message)); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java new file mode 100644 index 0000000000..aa1db52bec --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java @@ -0,0 +1,216 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; + +import lombok.extern.slf4j.Slf4j; + +/** + * A lightweight JSON-RPC 2.0 server that reads requests from an input stream + * and writes responses to an output stream (typically stdin/stdout for IDE integration). + * + * This implementation: + * - Supports JSON-RPC 2.0 specification + * - Handles single requests and batch requests + * - Supports notifications (requests without id) + * - Is compatible with GraalVM native image compilation + * - Runs in a single thread for simplicity (IDE integration use case) + * + * @author Ruud Senden + */ +@Slf4j +public final class JsonRpcServer { + private final ObjectMapper objectMapper; + private final Map methodHandlers; + private final ExecutorService executor; + private final AtomicBoolean running = new AtomicBoolean(false); + + public JsonRpcServer(ObjectMapper objectMapper, int threadPoolSize) { + this.objectMapper = objectMapper; + this.methodHandlers = new LinkedHashMap<>(); + this.executor = Executors.newFixedThreadPool(threadPoolSize); + registerDefaultMethods(); + } + + private void registerDefaultMethods() { + // Register built-in fcli methods + registerMethod("fcli.execute", new RpcMethodHandlerFcliExecute(objectMapper)); + registerMethod("fcli.listCommands", new RpcMethodHandlerFcliListCommands(objectMapper)); + registerMethod("fcli.version", new RpcMethodHandlerFcliVersion(objectMapper)); + registerMethod("rpc.listMethods", new RpcMethodHandlerListMethods(objectMapper, methodHandlers)); + } + + /** + * Register a custom method handler. + */ + public void registerMethod(String methodName, IRpcMethodHandler handler) { + methodHandlers.put(methodName, handler); + log.debug("Registered RPC method: {}", methodName); + } + + /** + * Start the server, reading from the given input stream and writing to the output stream. + * This method blocks until the input stream is closed or an error occurs. + */ + public void start(InputStream input, OutputStream output) { + running.set(true); + log.info("JSON-RPC server starting on stdio"); + System.err.println("Fcli JSON-RPC server running on stdio. Hit Ctrl-C to exit."); + + try (var reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8)); + var writer = new PrintWriter(output, true, StandardCharsets.UTF_8)) { + + String line; + while (running.get() && (line = reader.readLine()) != null) { + if (line.isBlank()) { + continue; + } + + log.debug("Received request: {}", line); + final String requestLine = line; + + // Process synchronously for stdio mode + String responseJson = processRequest(requestLine); + if (responseJson != null) { + log.debug("Sending response: {}", responseJson); + writer.println(responseJson); + } + } + } catch (Exception e) { + log.error("Error in JSON-RPC server", e); + } finally { + running.set(false); + executor.shutdown(); + log.info("JSON-RPC server stopped"); + } + } + + /** + * Stop the server gracefully. + */ + public void stop() { + running.set(false); + } + + /** + * Process a single JSON-RPC request line and return the response JSON. + * Returns null for notifications (requests without id). + * This method is package-private for testing purposes. + */ + public String processRequest(String requestJson) { + try { + JsonNode requestNode = objectMapper.readTree(requestJson); + + // Check for batch request + if (requestNode.isArray()) { + return processBatchRequest((ArrayNode) requestNode); + } + + // Single request + return processSingleRequest(requestNode); + } catch (JsonProcessingException e) { + log.warn("Failed to parse JSON-RPC request: {}", e.getMessage()); + return toJson(JsonRpcResponse.parseError()); + } + } + + private String processBatchRequest(ArrayNode requests) { + if (requests.isEmpty()) { + return toJson(JsonRpcResponse.invalidRequest(null)); + } + + ArrayNode responses = objectMapper.createArrayNode(); + for (JsonNode request : requests) { + String responseJson = processSingleRequest(request); + if (responseJson != null) { + try { + responses.add(objectMapper.readTree(responseJson)); + } catch (JsonProcessingException e) { + log.error("Error processing batch response", e); + } + } + } + + // If all requests were notifications, return nothing + if (responses.isEmpty()) { + return null; + } + + return toJson(responses); + } + + private String processSingleRequest(JsonNode requestNode) { + JsonRpcRequest request; + try { + request = objectMapper.treeToValue(requestNode, JsonRpcRequest.class); + } catch (JsonProcessingException e) { + return toJson(JsonRpcResponse.invalidRequest(null)); + } + + if (request == null || !request.isValid()) { + return toJson(JsonRpcResponse.invalidRequest(request != null ? request.id() : null)); + } + + // Process the method + JsonRpcResponse response = executeMethod(request); + + // Don't return response for notifications + if (request.isNotification()) { + return null; + } + + return toJson(response); + } + + private JsonRpcResponse executeMethod(JsonRpcRequest request) { + var handler = methodHandlers.get(request.method()); + if (handler == null) { + return JsonRpcResponse.methodNotFound(request.id(), request.method()); + } + + try { + JsonNode result = handler.execute(request.params()); + return JsonRpcResponse.success(request.id(), result); + } catch (RpcMethodException e) { + return JsonRpcResponse.error(request.id(), e.toJsonRpcError()); + } catch (Exception e) { + log.error("Unexpected error executing method {}: {}", request.method(), e.getMessage(), e); + return JsonRpcResponse.internalError(request.id(), e.getMessage()); + } + } + + private String toJson(Object obj) { + try { + return objectMapper.writeValueAsString(obj); + } catch (JsonProcessingException e) { + log.error("Failed to serialize response", e); + return "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal error: serialization failed\"},\"id\":null}"; + } + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodException.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodException.java new file mode 100644 index 0000000000..e5f7ad95fd --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodException.java @@ -0,0 +1,64 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Exception thrown by RPC method handlers to indicate a method execution error. + * This maps to JSON-RPC error responses. + * + * @author Ruud Senden + */ +public class RpcMethodException extends Exception { + private final int code; + private final JsonNode data; + + public RpcMethodException(int code, String message) { + this(code, message, null, null); + } + + public RpcMethodException(int code, String message, JsonNode data) { + this(code, message, data, null); + } + + public RpcMethodException(int code, String message, JsonNode data, Throwable cause) { + super(message, cause); + this.code = code; + this.data = data; + } + + public int getCode() { + return code; + } + + public JsonNode getData() { + return data; + } + + public JsonRpcError toJsonRpcError() { + return new JsonRpcError(code, getMessage(), data); + } + + public static RpcMethodException invalidParams(String message) { + return new RpcMethodException(JsonRpcError.INVALID_PARAMS, message); + } + + public static RpcMethodException internalError(String message) { + return new RpcMethodException(JsonRpcError.INTERNAL_ERROR, message); + } + + public static RpcMethodException internalError(String message, Throwable cause) { + return new RpcMethodException(JsonRpcError.INTERNAL_ERROR, message, null, cause); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecute.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecute.java new file mode 100644 index 0000000000..cb52a83012 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecute.java @@ -0,0 +1,152 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import java.util.ArrayList; +import java.util.List; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.cli.util.FcliCommandExecutorFactory; +import com.fortify.cli.common.util.OutputHelper.OutputType; +import com.fortify.cli.common.util.OutputHelper.Result; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for executing fcli commands. + * + * Method: fcli.execute + * Params: + * - command (string, required): The fcli command to execute (e.g., "ssc appversion list") + * - collectRecords (boolean, optional): If true, collect structured records instead of stdout + * - offset (integer, optional): For paging, the offset to start from (default: 0) + * - limit (integer, optional): For paging, the maximum number of records (default: 100) + * + * Returns: + * - exitCode (integer): The command exit code + * - records (array, optional): Array of record objects if collectRecords=true + * - stdout (string, optional): Standard output if collectRecords=false + * - stderr (string): Standard error output + * - pagination (object, optional): Pagination info for paged results + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFcliExecute implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + if (params == null || !params.has("command")) { + throw RpcMethodException.invalidParams("'command' parameter is required"); + } + + var command = params.get("command").asText(); + var collectRecords = params.has("collectRecords") && params.get("collectRecords").asBoolean(false); + var offset = params.has("offset") ? params.get("offset").asInt(0) : 0; + var limit = params.has("limit") ? params.get("limit").asInt(100) : 100; + + if (command == null || command.isBlank()) { + throw RpcMethodException.invalidParams("'command' cannot be empty"); + } + + log.debug("Executing fcli command: {} (collectRecords={}, offset={}, limit={})", + command, collectRecords, offset, limit); + + try { + if (collectRecords) { + return executeWithRecords(command, offset, limit); + } else { + return executeWithStdout(command); + } + } catch (Exception e) { + log.error("Error executing fcli command: {}", command, e); + throw RpcMethodException.internalError("Command execution failed: " + e.getMessage(), e); + } + } + + private JsonNode executeWithStdout(String command) { + var result = FcliCommandExecutorFactory.builder() + .cmd(command) + .stdoutOutputType(OutputType.collect) + .stderrOutputType(OutputType.collect) + .onFail(r -> {}) + .build().create().execute(); + + return buildResponse(result, null, null); + } + + private JsonNode executeWithRecords(String command, int offset, int limit) { + var allRecords = new ArrayList(); + + var result = FcliCommandExecutorFactory.builder() + .cmd(command) + .stdoutOutputType(OutputType.suppress) + .stderrOutputType(OutputType.collect) + .recordConsumer(allRecords::add) + .onFail(r -> {}) + .build().create().execute(); + + // Apply pagination + var totalRecords = allRecords.size(); + var endIndex = Math.min(offset + limit, totalRecords); + List pagedRecords = offset >= totalRecords + ? List.of() + : allRecords.subList(offset, endIndex); + + var pagination = buildPagination(offset, limit, totalRecords); + return buildResponse(result, pagedRecords, pagination); + } + + private ObjectNode buildResponse(Result result, List records, ObjectNode pagination) { + var response = objectMapper.createObjectNode(); + response.put("exitCode", result.getExitCode()); + + if (records != null) { + ArrayNode recordsArray = response.putArray("records"); + records.forEach(recordsArray::add); + } else { + response.put("stdout", result.getOut()); + } + + if (result.getErr() != null && !result.getErr().isBlank()) { + response.put("stderr", result.getErr()); + } + + if (pagination != null) { + response.set("pagination", pagination); + } + + return response; + } + + private ObjectNode buildPagination(int offset, int limit, int totalRecords) { + var pagination = objectMapper.createObjectNode(); + pagination.put("offset", offset); + pagination.put("limit", limit); + pagination.put("totalRecords", totalRecords); + pagination.put("totalPages", (int) Math.ceil((double) totalRecords / limit)); + pagination.put("hasMore", offset + limit < totalRecords); + + if (offset + limit < totalRecords) { + pagination.put("nextOffset", offset + limit); + } + + return pagination; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliListCommands.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliListCommands.java new file mode 100644 index 0000000000..9038c941b9 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliListCommands.java @@ -0,0 +1,127 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import java.util.stream.Stream; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.cli.util.FcliCommandSpecHelper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import picocli.CommandLine.Model.CommandSpec; + +/** + * RPC method handler for listing available fcli commands. + * + * Method: fcli.listCommands + * Params: + * - module (string, optional): Filter by module (e.g., "ssc", "fod") + * - runnableOnly (boolean, optional): If true, only return runnable (leaf) commands + * - includeHidden (boolean, optional): If true, include hidden commands + * + * Returns: + * - commands (array): Array of command descriptors with: + * - name (string): Qualified command name + * - module (string): The module this command belongs to + * - usageHeader (string): Short description + * - runnable (boolean): Whether the command is executable + * - hidden (boolean): Whether the command is hidden + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFcliListCommands implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + var module = params != null && params.has("module") + ? params.get("module").asText(null) : null; + var runnableOnly = params != null && params.has("runnableOnly") + && params.get("runnableOnly").asBoolean(false); + var includeHidden = params != null && params.has("includeHidden") + && params.get("includeHidden").asBoolean(false); + + log.debug("Listing fcli commands (module={}, runnableOnly={}, includeHidden={})", + module, runnableOnly, includeHidden); + + try { + var rootSpec = FcliCommandSpecHelper.getRootCommandLine().getCommandSpec(); + Stream commandStream = FcliCommandSpecHelper.commandTreeStream(rootSpec); + + // Apply filters + if (module != null && !module.isBlank()) { + commandStream = commandStream.filter(spec -> + spec.qualifiedName(" ").startsWith("fcli " + module + " ") || + spec.qualifiedName(" ").equals("fcli " + module)); + } + + if (runnableOnly) { + commandStream = commandStream.filter(FcliCommandSpecHelper::isRunnable); + } + + if (!includeHidden) { + commandStream = commandStream.filter(spec -> !spec.usageMessage().hidden()); + } + + ArrayNode commands = objectMapper.createArrayNode(); + commandStream + .map(this::specToDescriptor) + .forEach(commands::add); + + ObjectNode result = objectMapper.createObjectNode(); + result.set("commands", commands); + result.put("count", commands.size()); + + return result; + } catch (Exception e) { + log.error("Error listing fcli commands", e); + throw RpcMethodException.internalError("Failed to list commands: " + e.getMessage(), e); + } + } + + private ObjectNode specToDescriptor(CommandSpec spec) { + var descriptor = objectMapper.createObjectNode(); + var qualifiedName = spec.qualifiedName(" "); + + descriptor.put("name", qualifiedName); + descriptor.put("module", extractModule(qualifiedName)); + descriptor.put("usageHeader", getUsageHeader(spec)); + descriptor.put("runnable", FcliCommandSpecHelper.isRunnable(spec)); + descriptor.put("hidden", spec.usageMessage().hidden()); + + return descriptor; + } + + private String extractModule(String qualifiedName) { + // Format: "fcli ..." or just "fcli" + var parts = qualifiedName.split(" "); + if (parts.length >= 2) { + return parts[1]; + } + return ""; + } + + private String getUsageHeader(CommandSpec spec) { + var headerLines = spec.usageMessage().header(); + if (headerLines != null && headerLines.length > 0) { + return String.join(" ", headerLines); + } + return ""; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliVersion.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliVersion.java new file mode 100644 index 0000000000..80e0f2dd3c --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliVersion.java @@ -0,0 +1,50 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.util.FcliBuildProperties; + +import lombok.RequiredArgsConstructor; + +/** + * RPC method handler for getting fcli version information. + * + * Method: fcli.version + * Params: none + * + * Returns: + * - version (string): The fcli version + * - buildDate (string): The build date + * - actionSchemaVersion (string): The action schema version + * + * @author Ruud Senden + */ +@RequiredArgsConstructor +public final class RpcMethodHandlerFcliVersion implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + var props = FcliBuildProperties.INSTANCE; + + ObjectNode result = objectMapper.createObjectNode(); + result.put("version", props.getFcliVersion()); + result.put("buildDate", props.getFcliBuildDateString()); + result.put("actionSchemaVersion", props.getFcliActionSchemaVersion()); + + return result; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerListMethods.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerListMethods.java new file mode 100644 index 0000000000..f2096e41f9 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerListMethods.java @@ -0,0 +1,66 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import java.util.Map; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import lombok.RequiredArgsConstructor; + +/** + * RPC method handler for listing available RPC methods. + * + * Method: rpc.listMethods + * Params: none + * + * Returns: + * - methods (array): Array of method descriptors with: + * - name (string): Method name + * - description (string): Method description + * + * @author Ruud Senden + */ +@RequiredArgsConstructor +public final class RpcMethodHandlerListMethods implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final Map methodHandlers; + + private static final Map METHOD_DESCRIPTIONS = Map.of( + "fcli.execute", "Execute an fcli command and return structured results or stdout", + "fcli.listCommands", "List available fcli commands with optional filtering", + "fcli.version", "Get fcli version information", + "rpc.listMethods", "List available RPC methods" + ); + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + ArrayNode methods = objectMapper.createArrayNode(); + + for (String methodName : methodHandlers.keySet()) { + ObjectNode method = objectMapper.createObjectNode(); + method.put("name", methodName); + method.put("description", METHOD_DESCRIPTIONS.getOrDefault(methodName, "No description available")); + methods.add(method); + } + + ObjectNode result = objectMapper.createObjectNode(); + result.set("methods", methods); + result.put("count", methods.size()); + + return result; + } +} diff --git a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties index 7da5271b2b..c228d83a53 100644 --- a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties +++ b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties @@ -106,6 +106,48 @@ fcli.util.mcp-server.start.progress-threads = Number of threads used for updatin fcli.util.mcp-server.start.job-safe-return = Maximum time to wait synchronously for a job result before returning an in_progress placeholder. Specify duration like 25s, 2m, 1h. fcli.util.mcp-server.start.progress-interval = Interval between internal progress counter updates for long-running jobs. Specify duration (e.g. 500ms, 1s, 2s). +# fcli util rpc-server +fcli.util.rpc-server.usage.header = (PREVIEW) Manage fcli JSON-RPC server for IDE plugin integration +fcli.util.rpc-server.start.usage.header = (PREVIEW) Start fcli JSON-RPC server for IDE plugin integration +fcli.util.rpc-server.start.usage.description = The fcli JSON-RPC server provides a simple JSON-RPC 2.0 interface \ + for IDE plugins and other tools to interact with Fortify products through fcli. Unlike the MCP server which is \ + designed for LLM integration, the RPC server exposes a smaller set of general-purpose methods suitable for \ + programmatic access from IDE plugins.%n%n\ + The server reads JSON-RPC requests from stdin and writes responses to stdout, one JSON object per line.%n%n\ + Available RPC methods:%n\ + %n - fcli.execute: Execute any fcli command and return structured results\ + %n Parameters:\ + %n - command (string, required): The fcli command to execute (e.g., "ssc appversion list")\ + %n - collectRecords (boolean, optional): If true, collect structured records instead of stdout\ + %n - offset (integer, optional): For paging, the offset to start from (default: 0)\ + %n - limit (integer, optional): For paging, the maximum number of records (default: 100)\ + %n\ + %n - fcli.listCommands: List available fcli commands with optional filtering\ + %n Parameters:\ + %n - module (string, optional): Filter by module (e.g., "ssc", "fod")\ + %n - runnableOnly (boolean, optional): If true, only return runnable (leaf) commands\ + %n - includeHidden (boolean, optional): If true, include hidden commands\ + %n\ + %n - fcli.version: Get fcli version information\ + %n Parameters: none\ + %n\ + %n - rpc.listMethods: List available RPC methods\ + %n Parameters: none\ + %n%n\ + Example IDE plugin configuration (VS Code settings.json style):%n\ + %n{\ + %n "fortify.fcli.path": "/path/to/fcli",\ + %n "fortify.rpc.args": ["util", "rpc-server", "start"]\ + %n}\ + %n%n\ + Example JSON-RPC request/response:%n\ + %nRequest: {"jsonrpc":"2.0","method":"fcli.version","id":1}\ + %nResponse: {"jsonrpc":"2.0","result":{"version":"x.y.z","buildDate":"..."},"id":1}\ + %n%n\ + Note: Like the MCP server, you'll need to have active fcli sessions for each product you want to \ + interact with. Login sessions must be created separately using 'fcli session login' commands. +fcli.util.rpc-server.start.threads = Number of threads for processing RPC requests. Default is 4. + # fcli util sample-data fcli.util.sample-data.usage.header = (INTERNAL) Generate sample data fcli.util.sample-data.usage.description = These commands generate and output a fixed set of sample data \ diff --git a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java b/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java new file mode 100644 index 0000000000..b1ea02518f --- /dev/null +++ b/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java @@ -0,0 +1,247 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.unit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.util.rpc_server.helper.rpc.JsonRpcServer; + +/** + * Unit tests for {@link JsonRpcServer}. Tests the JSON-RPC 2.0 protocol handling + * including request parsing, response generation, and error handling. + * + * @author Ruud Senden + */ +class JsonRpcServerTest { + + private JsonRpcServer server; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + server = new JsonRpcServer(objectMapper, 2); + } + + @Test + void shouldReturnParseErrorForInvalidJson() throws Exception { + // Act + String response = server.processRequest("not valid json"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertEquals("2.0", node.get("jsonrpc").asText()); + assertNotNull(node.get("error")); + assertEquals(-32700, node.get("error").get("code").asInt()); + assertNull(node.get("result")); + } + + @Test + void shouldReturnInvalidRequestForMissingJsonrpcVersion() throws Exception { + // Act + String response = server.processRequest("{\"method\":\"test\",\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertEquals("2.0", node.get("jsonrpc").asText()); + assertNotNull(node.get("error")); + assertEquals(-32600, node.get("error").get("code").asInt()); + } + + @Test + void shouldReturnInvalidRequestForWrongJsonrpcVersion() throws Exception { + // Act + String response = server.processRequest("{\"jsonrpc\":\"1.0\",\"method\":\"test\",\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32600, node.get("error").get("code").asInt()); + } + + @Test + void shouldReturnMethodNotFoundForUnknownMethod() throws Exception { + // Act + String response = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"unknown.method\",\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertEquals("2.0", node.get("jsonrpc").asText()); + assertNotNull(node.get("error")); + assertEquals(-32601, node.get("error").get("code").asInt()); + assertTrue(node.get("error").get("message").asText().contains("unknown.method")); + assertEquals(1, node.get("id").asInt()); + } + + @Test + void shouldReturnNullForNotification() throws Exception { + // Notification = request without id + // Act + String response = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"fcli.version\"}"); + + // Assert - notifications should not return a response + assertNull(response); + } + + @Test + void shouldExecuteFcliVersionMethod() throws Exception { + // Act + String response = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"fcli.version\",\"id\":42}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertEquals("2.0", node.get("jsonrpc").asText()); + assertNotNull(node.get("result")); + assertNull(node.get("error")); + assertEquals(42, node.get("id").asInt()); + + // Check result contains version info + var result = node.get("result"); + assertTrue(result.has("version")); + assertTrue(result.has("buildDate")); + assertTrue(result.has("actionSchemaVersion")); + } + + @Test + void shouldExecuteRpcListMethodsMethod() throws Exception { + // Act + String response = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"rpc.listMethods\",\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertEquals("2.0", node.get("jsonrpc").asText()); + assertNotNull(node.get("result")); + assertNull(node.get("error")); + + // Check result contains methods list + var result = node.get("result"); + assertTrue(result.has("methods")); + assertTrue(result.get("methods").isArray()); + assertTrue(result.get("methods").size() >= 4); // At least our 4 default methods + assertTrue(result.has("count")); + } + + @Test + void shouldReturnInvalidParamsForExecuteWithoutCommand() throws Exception { + // Act + String response = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"fcli.execute\",\"params\":{},\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + assertTrue(node.get("error").get("message").asText().contains("command")); + } + + @Test + void shouldPreserveRequestIdInResponse() throws Exception { + // Test with string id + String response1 = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"fcli.version\",\"id\":\"string-id\"}"); + assertNotNull(response1); + var node1 = objectMapper.readTree(response1); + assertEquals("string-id", node1.get("id").asText()); + + // Test with numeric id + String response2 = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"fcli.version\",\"id\":999}"); + assertNotNull(response2); + var node2 = objectMapper.readTree(response2); + assertEquals(999, node2.get("id").asInt()); + } + + @Test + void shouldHandleBatchRequest() throws Exception { + // Act + String response = server.processRequest( + "[{\"jsonrpc\":\"2.0\",\"method\":\"fcli.version\",\"id\":1}," + + "{\"jsonrpc\":\"2.0\",\"method\":\"rpc.listMethods\",\"id\":2}]" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertTrue(node.isArray()); + assertEquals(2, node.size()); + + // Both responses should be successful + for (var responseNode : node) { + assertEquals("2.0", responseNode.get("jsonrpc").asText()); + assertNotNull(responseNode.get("result")); + assertNull(responseNode.get("error")); + } + } + + @Test + void shouldReturnInvalidRequestForEmptyBatch() throws Exception { + // Act + String response = server.processRequest("[]"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32600, node.get("error").get("code").asInt()); + } + + @Test + void shouldHandleNullId() throws Exception { + // Act - id is explicitly null (this is a notification) + String response = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"fcli.version\",\"id\":null}"); + + // Assert - no response for notifications + assertNull(response); + } + + @Test + void shouldHandleRequestWithNullParams() throws Exception { + // Act + String response = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"fcli.version\",\"params\":null,\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("result")); + assertNull(node.get("error")); + } + + @Test + void shouldReturnErrorForListCommandsWithoutAppContext() throws Exception { + // Note: fcli.listCommands requires the full fcli command tree to be initialized, + // which isn't available in unit tests. This test verifies that the method + // returns an error response rather than crashing. + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.listCommands\",\"params\":{},\"id\":1}" + ); + + // Either we get an error (expected in unit test context) or a result (if running in full context) + assertNotNull(response); + var node = objectMapper.readTree(response); + assertEquals("2.0", node.get("jsonrpc").asText()); + // In unit test context, we expect an error since the command tree isn't initialized + // but the important thing is that it doesn't crash + assertTrue(node.has("error") || node.has("result")); + } +} From 7786d94cc13ca9af564b59e0db856120afe4d172 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Nov 2025 05:34:32 +0000 Subject: [PATCH 3/7] Address code review feedback - Remove unused ExecutorService and thread pool parameter from JsonRpcServer - Add validation for limit (must be > 0) and offset (must be >= 0) parameters - Cache qualifiedName in local variable in filter lambda - Use JsonRpcError.INTERNAL_ERROR constant in fallback error response Co-authored-by: rsenden <8635138+rsenden@users.noreply.github.com> --- .../cli/cmd/RPCServerStartCommand.java | 10 +++---- .../rpc_server/helper/rpc/JsonRpcServer.java | 21 ++++++------- .../rpc/RpcMethodHandlerFcliExecute.java | 8 +++++ .../rpc/RpcMethodHandlerFcliListCommands.java | 9 ++++-- .../cli/util/i18n/UtilMessages.properties | 1 - .../rpc_server/unit/JsonRpcServerTest.java | 30 ++++++++++++++++++- 6 files changed, 56 insertions(+), 23 deletions(-) diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java index f53ef6ace5..582eb0250e 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java @@ -20,11 +20,11 @@ import lombok.extern.slf4j.Slf4j; import picocli.CommandLine.Command; -import picocli.CommandLine.Option; /** * Command to start the fcli JSON-RPC server for IDE plugin integration. - * The server listens on stdin/stdout for JSON-RPC 2.0 requests. + * The server listens on stdin/stdout for JSON-RPC 2.0 requests and processes + * them synchronously. * * @author Ruud Senden */ @@ -32,15 +32,13 @@ @MCPExclude @Slf4j public class RPCServerStartCommand extends AbstractRunnableCommand { - @Option(names = {"--threads", "-t"}, defaultValue = "4") - private int threads; @Override public Integer call() throws Exception { - log.info("Starting JSON-RPC server with {} threads", threads); + log.info("Starting JSON-RPC server"); var objectMapper = new ObjectMapper(); - var server = new JsonRpcServer(objectMapper, threads); + var server = new JsonRpcServer(objectMapper); // Start the server on stdin/stdout server.start(System.in, System.out); diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java index aa1db52bec..7d9ef6effc 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java @@ -20,8 +20,6 @@ import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; import com.fasterxml.jackson.core.JsonProcessingException; @@ -40,7 +38,7 @@ * - Handles single requests and batch requests * - Supports notifications (requests without id) * - Is compatible with GraalVM native image compilation - * - Runs in a single thread for simplicity (IDE integration use case) + * - Processes requests synchronously (appropriate for stdio-based IDE integration) * * @author Ruud Senden */ @@ -48,13 +46,11 @@ public final class JsonRpcServer { private final ObjectMapper objectMapper; private final Map methodHandlers; - private final ExecutorService executor; private final AtomicBoolean running = new AtomicBoolean(false); - public JsonRpcServer(ObjectMapper objectMapper, int threadPoolSize) { + public JsonRpcServer(ObjectMapper objectMapper) { this.objectMapper = objectMapper; this.methodHandlers = new LinkedHashMap<>(); - this.executor = Executors.newFixedThreadPool(threadPoolSize); registerDefaultMethods(); } @@ -77,6 +73,7 @@ public void registerMethod(String methodName, IRpcMethodHandler handler) { /** * Start the server, reading from the given input stream and writing to the output stream. * This method blocks until the input stream is closed or an error occurs. + * Requests are processed synchronously in the order they are received. */ public void start(InputStream input, OutputStream output) { running.set(true); @@ -93,10 +90,8 @@ public void start(InputStream input, OutputStream output) { } log.debug("Received request: {}", line); - final String requestLine = line; - // Process synchronously for stdio mode - String responseJson = processRequest(requestLine); + String responseJson = processRequest(line); if (responseJson != null) { log.debug("Sending response: {}", responseJson); writer.println(responseJson); @@ -106,7 +101,6 @@ public void start(InputStream input, OutputStream output) { log.error("Error in JSON-RPC server", e); } finally { running.set(false); - executor.shutdown(); log.info("JSON-RPC server stopped"); } } @@ -121,7 +115,6 @@ public void stop() { /** * Process a single JSON-RPC request line and return the response JSON. * Returns null for notifications (requests without id). - * This method is package-private for testing purposes. */ public String processRequest(String requestJson) { try { @@ -210,7 +203,11 @@ private String toJson(Object obj) { return objectMapper.writeValueAsString(obj); } catch (JsonProcessingException e) { log.error("Failed to serialize response", e); - return "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal error: serialization failed\"},\"id\":null}"; + // Fallback to a hardcoded error response to avoid infinite recursion + // if serialization itself fails + return String.format( + "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":%d,\"message\":\"Internal error: serialization failed\"},\"id\":null}", + JsonRpcError.INTERNAL_ERROR); } } } diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecute.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecute.java index cb52a83012..4bc10290ce 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecute.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecute.java @@ -65,6 +65,14 @@ public JsonNode execute(JsonNode params) throws RpcMethodException { throw RpcMethodException.invalidParams("'command' cannot be empty"); } + if (offset < 0) { + throw RpcMethodException.invalidParams("'offset' must be non-negative"); + } + + if (limit <= 0) { + throw RpcMethodException.invalidParams("'limit' must be greater than 0"); + } + log.debug("Executing fcli command: {} (collectRecords={}, offset={}, limit={})", command, collectRecords, offset, limit); diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliListCommands.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliListCommands.java index 9038c941b9..9f3010cd56 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliListCommands.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliListCommands.java @@ -66,9 +66,12 @@ public JsonNode execute(JsonNode params) throws RpcMethodException { // Apply filters if (module != null && !module.isBlank()) { - commandStream = commandStream.filter(spec -> - spec.qualifiedName(" ").startsWith("fcli " + module + " ") || - spec.qualifiedName(" ").equals("fcli " + module)); + final String modulePrefix = "fcli " + module + " "; + final String moduleExact = "fcli " + module; + commandStream = commandStream.filter(spec -> { + var qualifiedName = spec.qualifiedName(" "); + return qualifiedName.startsWith(modulePrefix) || qualifiedName.equals(moduleExact); + }); } if (runnableOnly) { diff --git a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties index c228d83a53..9ed037c3fc 100644 --- a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties +++ b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties @@ -146,7 +146,6 @@ fcli.util.rpc-server.start.usage.description = The fcli JSON-RPC server provides %n%n\ Note: Like the MCP server, you'll need to have active fcli sessions for each product you want to \ interact with. Login sessions must be created separately using 'fcli session login' commands. -fcli.util.rpc-server.start.threads = Number of threads for processing RPC requests. Default is 4. # fcli util sample-data fcli.util.sample-data.usage.header = (INTERNAL) Generate sample data diff --git a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java b/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java index b1ea02518f..d8902462c8 100644 --- a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java +++ b/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java @@ -37,7 +37,7 @@ class JsonRpcServerTest { @BeforeEach void setUp() { objectMapper = new ObjectMapper(); - server = new JsonRpcServer(objectMapper, 2); + server = new JsonRpcServer(objectMapper); } @Test @@ -157,6 +157,34 @@ void shouldReturnInvalidParamsForExecuteWithoutCommand() throws Exception { assertTrue(node.get("error").get("message").asText().contains("command")); } + @Test + void shouldReturnInvalidParamsForZeroLimit() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.execute\",\"params\":{\"command\":\"util sample-data list\",\"collectRecords\":true,\"limit\":0},\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + assertTrue(node.get("error").get("message").asText().contains("limit")); + } + + @Test + void shouldReturnInvalidParamsForNegativeOffset() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.execute\",\"params\":{\"command\":\"util sample-data list\",\"collectRecords\":true,\"offset\":-5},\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + assertTrue(node.get("error").get("message").asText().contains("offset")); + } + @Test void shouldPreserveRequestIdInResponse() throws Exception { // Test with string id From 0c633c0141242629d2834f37b7ea6dabf099f631 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Nov 2025 11:52:52 +0000 Subject: [PATCH 4/7] Add caching and async execution support to JSON-RPC server - Create shared _common/helper package with reusable components: - FcliRecordsCache: Background loading and caching for large result sets - FcliToolResult: Unified result class for fcli command execution - FcliRunnerHelper: Helper methods for running fcli commands - Add new RPC methods for async execution and cache management: - fcli.executeAsync: Start background collection, returns cacheKey - fcli.getPage: Retrieve page from cache by cacheKey with wait option - fcli.cancelCollection: Cancel in-progress collection - fcli.clearCache: Clear cache entries - Update documentation and tests for new methods Co-authored-by: rsenden <8635138+rsenden@users.noreply.github.com> --- .../util/_common/helper/FcliRecordsCache.java | 352 ++++++++++++++++++ .../util/_common/helper/FcliRunnerHelper.java | 73 ++++ .../util/_common/helper/FcliToolResult.java | 241 ++++++++++++ .../rpc_server/helper/rpc/JsonRpcServer.java | 9 + .../RpcMethodHandlerFcliCancelCollection.java | 66 ++++ .../rpc/RpcMethodHandlerFcliClearCache.java | 74 ++++ .../rpc/RpcMethodHandlerFcliExecuteAsync.java | 70 ++++ .../rpc/RpcMethodHandlerFcliGetPage.java | 196 ++++++++++ .../rpc/RpcMethodHandlerListMethods.java | 6 +- .../cli/util/i18n/UtilMessages.properties | 25 +- .../rpc_server/unit/JsonRpcServerTest.java | 127 +++++++ 11 files changed, 1237 insertions(+), 2 deletions(-) create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRecordsCache.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRunnerHelper.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliToolResult.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliCancelCollection.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliClearCache.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecuteAsync.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliGetPage.java diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRecordsCache.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRecordsCache.java new file mode 100644 index 0000000000..025d32d5f7 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRecordsCache.java @@ -0,0 +1,352 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util._common.helper; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; + +import com.fasterxml.jackson.databind.JsonNode; + +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Cache for fcli record-collecting operations. Provides background loading with + * progressive record access, suitable for both MCP and RPC servers. + * + * Features: + * - LRU cache with configurable size and TTL + * - Background async loading with partial result access + * - Cancel support for long-running collections + * - Thread-safe concurrent access + * + * @author Ruud Senden + */ +@Slf4j +public class FcliRecordsCache { + private static final long DEFAULT_TTL = 10 * 60 * 1000; // 10 minutes + private static final int DEFAULT_MAX_ENTRIES = 5; + private static final int DEFAULT_BG_THREADS = 2; + + private final long ttl; + private final int maxEntries; + private final Map cache; + private final Map inProgress = new ConcurrentHashMap<>(); + private final ExecutorService backgroundExecutor; + + public FcliRecordsCache() { + this(DEFAULT_MAX_ENTRIES, DEFAULT_TTL, DEFAULT_BG_THREADS); + } + + public FcliRecordsCache(int maxEntries, long ttlMillis, int bgThreads) { + this.ttl = ttlMillis; + this.maxEntries = maxEntries; + // Use access-ordered LinkedHashMap for LRU behavior + this.cache = new LinkedHashMap<>(maxEntries, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > maxEntries; + } + }; + this.backgroundExecutor = Executors.newFixedThreadPool(bgThreads, r -> { + var t = new Thread(r, "fcli-cache-loader"); + t.setDaemon(true); + return t; + }); + log.info("Initialized FcliRecordsCache: maxEntries={} ttl={}ms bgThreads={}", maxEntries, ttlMillis, bgThreads); + } + + /** + * Get cached result, or start background collection if not cached. + * Returns null if result is already cached (caller should use getCached). + * Returns InProgressEntry if background collection started/exists. + */ + public InProgressEntry getOrStartBackground(String cacheKey, boolean refresh, String command) { + var cached = getCached(cacheKey); + if (!refresh && cached != null) { + return null; // Already cached + } + + var existing = inProgress.get(cacheKey); + if (existing != null && !existing.isExpired(ttl)) { + return existing; // Already loading + } + + return startNewBackgroundCollection(cacheKey, command); + } + + /** + * Start a background collection and return immediately with the cacheKey. + */ + public String startBackgroundCollection(String command) { + var cacheKey = UUID.randomUUID().toString(); + startNewBackgroundCollection(cacheKey, command); + return cacheKey; + } + + private InProgressEntry startNewBackgroundCollection(String cacheKey, String command) { + var entry = new InProgressEntry(cacheKey, command); + inProgress.put(cacheKey, entry); + + var future = buildCollectionFuture(entry, command); + future.whenComplete(createCompletionHandler(entry, cacheKey)); + + entry.setFuture(future); + log.debug("Started background collection: cacheKey={} command={}", cacheKey, command); + + return entry; + } + + private CompletableFuture buildCollectionFuture(InProgressEntry entry, String command) { + return CompletableFuture.supplyAsync(() -> { + var records = entry.getRecords(); + var result = FcliRunnerHelper.collectRecords(command, record -> { + if (!Thread.currentThread().isInterrupted()) { + records.add(record); + } + }); + + if (Thread.currentThread().isInterrupted()) { + return null; + } + + var fullResult = FcliToolResult.fromRecords(result, records); + if (result.getExitCode() == 0) { + put(entry.getCacheKey(), fullResult); + } + return fullResult; + }, backgroundExecutor); + } + + private BiConsumer createCompletionHandler(InProgressEntry entry, String cacheKey) { + return (result, throwable) -> { + entry.setCompleted(true); + captureExecutionResult(entry, result, throwable); + cleanupFailedCollection(entry, cacheKey); + log.debug("Background collection completed: cacheKey={} exitCode={}", cacheKey, entry.getExitCode()); + }; + } + + private void captureExecutionResult(InProgressEntry entry, FcliToolResult result, Throwable throwable) { + if (throwable != null) { + entry.setExitCode(999); + entry.setStderr(throwable.getMessage() != null ? throwable.getMessage() : "Background collection failed"); + } else if (result != null) { + entry.setExitCode(result.getExitCode()); + entry.setStderr(result.getStderr()); + } else { + entry.setExitCode(999); + entry.setStderr("Cancelled"); + } + } + + private void cleanupFailedCollection(InProgressEntry entry, String cacheKey) { + if (entry.getExitCode() != 0) { + inProgress.remove(cacheKey); + } + } + + /** + * Store a result in the cache. + */ + public void put(String cacheKey, FcliToolResult result) { + if (result == null) { + return; + } + synchronized (cache) { + cache.put(cacheKey, new CacheEntry(result)); + } + log.debug("Cached result: cacheKey={} records={}", cacheKey, result.getRecords() != null ? result.getRecords().size() : 0); + } + + /** + * Get a cached result if present and not expired. + */ + public FcliToolResult getCached(String cacheKey) { + synchronized (cache) { + var entry = cache.get(cacheKey); + return entry == null || entry.isExpired(ttl) ? null : entry.getFullResult(); + } + } + + /** + * Get an in-progress entry if exists. + */ + public InProgressEntry getInProgress(String cacheKey) { + return inProgress.get(cacheKey); + } + + /** + * Wait for collection to complete (up to maxWaitMs) and return the result. + */ + public FcliToolResult waitForCompletion(String cacheKey, long maxWaitMs) { + var entry = inProgress.get(cacheKey); + if (entry == null) { + return getCached(cacheKey); + } + + long start = System.currentTimeMillis(); + while (!entry.isCompleted() && System.currentTimeMillis() - start < maxWaitMs) { + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + + if (entry.isCompleted()) { + inProgress.remove(cacheKey); + return getCached(cacheKey); + } + + return null; // Still in progress + } + + /** + * Cancel a background collection. + */ + public boolean cancel(String cacheKey) { + var entry = inProgress.get(cacheKey); + if (entry != null) { + entry.cancel(); + inProgress.remove(cacheKey); + log.debug("Cancelled collection: cacheKey={}", cacheKey); + return true; + } + return false; + } + + /** + * Clear a specific cache entry. + */ + public boolean clear(String cacheKey) { + boolean removed = false; + synchronized (cache) { + removed = cache.remove(cacheKey) != null; + } + var inProg = inProgress.remove(cacheKey); + if (inProg != null) { + inProg.cancel(); + removed = true; + } + return removed; + } + + /** + * Clear all cache entries. + */ + public void clearAll() { + synchronized (cache) { + cache.clear(); + } + inProgress.values().forEach(InProgressEntry::cancel); + inProgress.clear(); + log.debug("Cleared all cache entries"); + } + + /** + * Get cache statistics. + */ + public CacheStats getStats() { + int cached; + synchronized (cache) { + cached = cache.size(); + } + return new CacheStats(cached, inProgress.size()); + } + + /** + * Shutdown the cache and background executor. + */ + public void shutdown() { + backgroundExecutor.shutdown(); + try { + backgroundExecutor.awaitTermination(2, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + backgroundExecutor.shutdownNow(); + log.info("FcliRecordsCache shutdown complete"); + } + + /** + * In-progress tracking entry giving access to partial records list. + */ + @Data + public static final class InProgressEntry { + private final String cacheKey; + private final String command; + private final long created = System.currentTimeMillis(); + private final CopyOnWriteArrayList records = new CopyOnWriteArrayList<>(); + private volatile CompletableFuture future; + private volatile boolean completed = false; + private volatile int exitCode = 0; + private volatile String stderr = ""; + + public InProgressEntry(String cacheKey, String command) { + this.cacheKey = cacheKey; + this.command = command; + } + + public boolean isExpired(long ttl) { + return System.currentTimeMillis() > created + ttl; + } + + public void setFuture(CompletableFuture f) { + this.future = f; + } + + public void cancel() { + if (future != null) { + future.cancel(true); + } + } + + public int getLoadedCount() { + return records.size(); + } + + public List getRecordsSnapshot() { + return List.copyOf(records); + } + } + + @Data + @RequiredArgsConstructor + private static final class CacheEntry { + private final FcliToolResult fullResult; + private final long created = System.currentTimeMillis(); + + public boolean isExpired(long ttl) { + return System.currentTimeMillis() > created + ttl; + } + } + + @Data + @RequiredArgsConstructor + public static final class CacheStats { + private final int cachedEntries; + private final int inProgressEntries; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRunnerHelper.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRunnerHelper.java new file mode 100644 index 0000000000..1187e93668 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRunnerHelper.java @@ -0,0 +1,73 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util._common.helper; + +import java.util.ArrayList; +import java.util.function.Consumer; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.cli.util.FcliCommandExecutorFactory; +import com.fortify.cli.common.util.OutputHelper.OutputType; +import com.fortify.cli.common.util.OutputHelper.Result; + +/** + * Helper methods for running fcli commands, collecting either records or stdout. + * This class is shared between MCP server and RPC server implementations. + * + * @author Ruud Senden + */ +public class FcliRunnerHelper { + + /** + * Execute a command and collect stdout output. + */ + public static Result collectStdout(String fullCmd) { + return FcliCommandExecutorFactory.builder() + .cmd(fullCmd) + .stdoutOutputType(OutputType.collect) + .stderrOutputType(OutputType.collect) + .onFail(r -> {}) + .build().create().execute(); + } + + /** + * Execute a command and collect structured records. + */ + public static Result collectRecords(String fullCmd, Consumer recordConsumer) { + return FcliCommandExecutorFactory.builder() + .cmd(fullCmd) + .stdoutOutputType(OutputType.suppress) + .stderrOutputType(OutputType.collect) + .recordConsumer(recordConsumer) + .onFail(r -> {}) + .build().create().execute(); + } + + /** + * Execute a command and return a FcliToolResult with all collected records. + */ + public static FcliToolResult collectRecordsAsResult(String fullCmd) { + var records = new ArrayList(); + var result = collectRecords(fullCmd, records::add); + return FcliToolResult.fromRecords(result, records); + } + + /** + * Execute a command and return a FcliToolResult with stdout. + */ + public static FcliToolResult collectStdoutAsResult(String fullCmd) { + var result = collectStdout(fullCmd); + return FcliToolResult.fromPlainText(result); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliToolResult.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliToolResult.java new file mode 100644 index 0000000000..7877998ac7 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliToolResult.java @@ -0,0 +1,241 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util._common.helper; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.databind.JsonNode; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.exception.FcliExceptionHelper; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.util.OutputHelper.Result; + +import lombok.Builder; +import lombok.Data; + +/** + * Unified result class for fcli command execution. Supports multiple output formats: + * plain text (stdout), structured records, paginated records, and errors. + * Null fields are excluded from JSON serialization. + * + * This class is shared between MCP server and RPC server implementations. + * + * @author Ruud Senden + */ +@Data @Builder +@Reflectable +@JsonInclude(Include.NON_NULL) +public class FcliToolResult { + private static final Logger LOG = LoggerFactory.getLogger(FcliToolResult.class); + + // Common fields for all result types + private final Integer exitCode; + private final String stderr; + + // Error fields (populated when exitCode != 0) + private final String error; + private final String errorStackTrace; + private final String errorGuidance; + + // Plain text output + private final String stdout; + + // Structured records output + private final List records; + + // Pagination metadata (for paged results) + private final PageInfo pagination; + + // Factory methods + + /** + * Create result from fcli execution with plain text stdout. + */ + public static FcliToolResult fromPlainText(Result result) { + return builder() + .exitCode(result.getExitCode()) + .stderr(result.getErr()) + .stdout(result.getOut()) + .build(); + } + + /** + * Create result from fcli execution with structured records. + */ + public static FcliToolResult fromRecords(Result result, List records) { + return builder() + .exitCode(result.getExitCode()) + .stderr(result.getErr()) + .records(records) + .build(); + } + + /** + * Create complete paged result once all records have been collected. + */ + public static FcliToolResult fromCompletedPagedResult(FcliToolResult plainResult, int offset, int limit) { + var allRecords = plainResult.getRecords(); + var pageInfo = PageInfo.complete(allRecords.size(), offset, limit); + var endIndexExclusive = Math.min(offset+limit, allRecords.size()); + List pageRecords = offset>=endIndexExclusive ? List.of() : allRecords.subList(offset, endIndexExclusive); + return builder() + .exitCode(plainResult.getExitCode()) + .stderr(plainResult.getStderr()) + .error(plainResult.getError()) + .errorStackTrace(plainResult.getErrorStackTrace()) + .errorGuidance(plainResult.getErrorGuidance()) + .records(pageRecords) + .pagination(pageInfo) + .build(); + } + + /** + * Create partial paged result while background collection is still running. + */ + public static FcliToolResult fromPartialPagedResult(List loadedRecords, int offset, int limit, boolean complete, String cacheKey) { + if ( complete ) { + return fromCompletedPagedResult( + builder().exitCode(0).stderr("").records(loadedRecords).build(), + offset, limit); + } + var endIndexExclusive = Math.min(offset+limit, loadedRecords.size()); + List pageRecords = offset>=endIndexExclusive ? List.of() : loadedRecords.subList(offset, endIndexExclusive); + var hasMore = loadedRecords.size() > offset+limit; + var pageInfo = PageInfo.partial(offset, limit, hasMore).toBuilder().cacheKey(cacheKey).build(); + return builder() + .exitCode(0) + .stderr("") + .records(pageRecords) + .pagination(pageInfo) + .build(); + } + + /** + * Create error result from exit code and stderr message. + */ + public static FcliToolResult fromError(int exitCode, String stderr) { + return builder() + .exitCode(exitCode) + .stderr(stderr != null ? stderr : "Unknown error") + .records(List.of()) + .build(); + } + + /** + * Create error result from exception with structured error information. + */ + public static FcliToolResult fromError(Exception e) { + return builder() + .exitCode(1) + .stderr(getErrorMessage(e)) + .error(getErrorMessage(e)) + .errorStackTrace(formatException(e)) + .errorGuidance(getErrorGuidance()) + .records(List.of()) + .build(); + } + + /** + * Create error result with simple message. + */ + public static FcliToolResult fromError(String message) { + return fromError(1, message); + } + + // Conversion to JSON + + public final String asJsonString() { + return JsonHelper.getObjectMapper().valueToTree(this).toPrettyString(); + } + + public final JsonNode asJsonNode() { + return JsonHelper.getObjectMapper().valueToTree(this); + } + + // Pagination metadata inner class + + @Data @Builder(toBuilder = true) + @Reflectable + public static final class PageInfo { + private final Integer totalRecords; + private final Integer totalPages; + private final int currentOffset; + private final int currentLimit; + private final Integer nextPageOffset; + private final Integer lastPageOffset; + private final boolean hasMore; + private final boolean complete; + private final String cacheKey; // For RPC: reference to cached result + private final String jobToken; // For MCP: reference to job tracking + private final String guidance; + + public static PageInfo complete(int totalRecords, int offset, int limit) { + var totalPages = (int)Math.ceil((double)totalRecords / (double)limit); + var lastPageOffset = (totalPages - 1) * limit; + var nextPageOffset = offset+limit; + var hasMore = totalRecords>nextPageOffset; + return PageInfo.builder() + .currentLimit(limit) + .currentOffset(offset) + .lastPageOffset(lastPageOffset) + .nextPageOffset(hasMore ? nextPageOffset : null) + .hasMore(hasMore) + .totalRecords(totalRecords) + .totalPages(totalPages) + .complete(true) + .guidance("All records loaded; totals available.") + .build(); + } + + public static PageInfo partial(int offset, int limit, boolean hasMore) { + return PageInfo.builder() + .currentLimit(limit) + .currentOffset(offset) + .nextPageOffset(hasMore ? offset+limit : null) + .hasMore(hasMore) + .complete(false) + .guidance("Partial page; totals unavailable. Use cacheKey/jobToken to wait for completion.") + .build(); + } + + @JsonIgnore + public boolean isComplete() { + return complete; + } + } + + // Exception formatting helpers + + private static String formatException(Exception e) { + return FcliExceptionHelper.formatException(e); + } + + private static String getErrorMessage(Exception e) { + return FcliExceptionHelper.getErrorMessage(e); + } + + private static String getErrorGuidance() { + return """ + The fcli command failed with an exception. You may use the error message and stack trace to: + 1. Diagnose the root cause and suggest corrective actions to resolve the issue + 2. Provide the error details to the user if manual troubleshooting is required + 3. Adjust command parameters or suggest alternative approaches to accomplish the task + """; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java index 7d9ef6effc..ba942563cc 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java @@ -26,6 +26,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fortify.cli.util._common.helper.FcliRecordsCache; import lombok.extern.slf4j.Slf4j; @@ -39,6 +40,7 @@ * - Supports notifications (requests without id) * - Is compatible with GraalVM native image compilation * - Processes requests synchronously (appropriate for stdio-based IDE integration) + * - Includes caching for efficient paged access to large result sets * * @author Ruud Senden */ @@ -47,16 +49,22 @@ public final class JsonRpcServer { private final ObjectMapper objectMapper; private final Map methodHandlers; private final AtomicBoolean running = new AtomicBoolean(false); + private final FcliRecordsCache cache; public JsonRpcServer(ObjectMapper objectMapper) { this.objectMapper = objectMapper; this.methodHandlers = new LinkedHashMap<>(); + this.cache = new FcliRecordsCache(); registerDefaultMethods(); } private void registerDefaultMethods() { // Register built-in fcli methods registerMethod("fcli.execute", new RpcMethodHandlerFcliExecute(objectMapper)); + registerMethod("fcli.executeAsync", new RpcMethodHandlerFcliExecuteAsync(objectMapper, cache)); + registerMethod("fcli.getPage", new RpcMethodHandlerFcliGetPage(objectMapper, cache)); + registerMethod("fcli.cancelCollection", new RpcMethodHandlerFcliCancelCollection(objectMapper, cache)); + registerMethod("fcli.clearCache", new RpcMethodHandlerFcliClearCache(objectMapper, cache)); registerMethod("fcli.listCommands", new RpcMethodHandlerFcliListCommands(objectMapper)); registerMethod("fcli.version", new RpcMethodHandlerFcliVersion(objectMapper)); registerMethod("rpc.listMethods", new RpcMethodHandlerListMethods(objectMapper, methodHandlers)); @@ -101,6 +109,7 @@ public void start(InputStream input, OutputStream output) { log.error("Error in JSON-RPC server", e); } finally { running.set(false); + cache.shutdown(); log.info("JSON-RPC server stopped"); } } diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliCancelCollection.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliCancelCollection.java new file mode 100644 index 0000000000..701a73c1bc --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliCancelCollection.java @@ -0,0 +1,66 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.util._common.helper.FcliRecordsCache; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for cancelling an in-progress collection. + * + * Method: fcli.cancelCollection + * Params: + * - cacheKey (string, required): Cache key from fcli.executeAsync + * + * Returns: + * - success (boolean): Whether cancellation was successful + * - message (string): Human-readable status message + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFcliCancelCollection implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final FcliRecordsCache cache; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + if (params == null || !params.has("cacheKey")) { + throw RpcMethodException.invalidParams("'cacheKey' parameter is required"); + } + + var cacheKey = params.get("cacheKey").asText(); + if (cacheKey == null || cacheKey.isBlank()) { + throw RpcMethodException.invalidParams("'cacheKey' cannot be empty"); + } + + log.debug("Cancelling collection: cacheKey={}", cacheKey); + + var cancelled = cache.cancel(cacheKey); + + ObjectNode result = objectMapper.createObjectNode(); + result.put("success", cancelled); + result.put("cacheKey", cacheKey); + result.put("message", cancelled + ? "Collection cancelled successfully" + : "No in-progress collection found for this cacheKey"); + + return result; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliClearCache.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliClearCache.java new file mode 100644 index 0000000000..4b41d4039c --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliClearCache.java @@ -0,0 +1,74 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.util._common.helper.FcliRecordsCache; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for clearing cache entries. + * + * Method: fcli.clearCache + * Params: + * - cacheKey (string, optional): Specific cache key to clear. If not provided, clears all. + * + * Returns: + * - success (boolean): Whether operation was successful + * - message (string): Human-readable status message + * - stats (object, optional): Cache statistics after clearing + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFcliClearCache implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final FcliRecordsCache cache; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + var cacheKey = params != null && params.has("cacheKey") + ? params.get("cacheKey").asText() + : null; + + ObjectNode result = objectMapper.createObjectNode(); + + if (cacheKey != null && !cacheKey.isBlank()) { + log.debug("Clearing cache entry: cacheKey={}", cacheKey); + var cleared = cache.clear(cacheKey); + result.put("success", cleared); + result.put("cacheKey", cacheKey); + result.put("message", cleared + ? "Cache entry cleared successfully" + : "No cache entry found for this cacheKey"); + } else { + log.debug("Clearing all cache entries"); + cache.clearAll(); + result.put("success", true); + result.put("message", "All cache entries cleared"); + } + + // Add current stats + var stats = cache.getStats(); + ObjectNode statsNode = result.putObject("stats"); + statsNode.put("cachedEntries", stats.getCachedEntries()); + statsNode.put("inProgressEntries", stats.getInProgressEntries()); + + return result; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecuteAsync.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecuteAsync.java new file mode 100644 index 0000000000..bded6dc23a --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecuteAsync.java @@ -0,0 +1,70 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.util._common.helper.FcliRecordsCache; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for starting async fcli command execution with caching. + * + * Method: fcli.executeAsync + * Params: + * - command (string, required): The fcli command to execute (e.g., "ssc issue list") + * + * Returns: + * - cacheKey (string): Key to retrieve results via fcli.getPage + * - status (string): "started" or "cached" + * - message (string): Human-readable status message + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFcliExecuteAsync implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final FcliRecordsCache cache; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + if (params == null || !params.has("command")) { + throw RpcMethodException.invalidParams("'command' parameter is required"); + } + + var command = params.get("command").asText(); + if (command == null || command.isBlank()) { + throw RpcMethodException.invalidParams("'command' cannot be empty"); + } + + log.debug("Starting async execution: command={}", command); + + try { + var cacheKey = cache.startBackgroundCollection(command); + + ObjectNode result = objectMapper.createObjectNode(); + result.put("cacheKey", cacheKey); + result.put("status", "started"); + result.put("message", "Background collection started. Use fcli.getPage with this cacheKey to retrieve results."); + + return result; + } catch (Exception e) { + log.error("Error starting async execution: {}", command, e); + throw RpcMethodException.internalError("Failed to start async execution: " + e.getMessage(), e); + } + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliGetPage.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliGetPage.java new file mode 100644 index 0000000000..3198d931d8 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliGetPage.java @@ -0,0 +1,196 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import java.util.List; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.util._common.helper.FcliRecordsCache; +import com.fortify.cli.util._common.helper.FcliToolResult; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for retrieving a page of results from cache. + * + * Method: fcli.getPage + * Params: + * - cacheKey (string, required): Cache key from fcli.executeAsync + * - offset (integer, optional): Start offset (default: 0) + * - limit (integer, optional): Maximum records to return (default: 100) + * - wait (boolean, optional): If true, wait for completion if still loading (default: false) + * - waitTimeoutMs (integer, optional): Max time to wait in ms (default: 30000) + * + * Returns: + * - status (string): "complete", "partial", "loading", "not_found", or "error" + * - records (array): Array of record objects for this page + * - pagination (object): Pagination metadata + * - loadedCount (integer): Number of records loaded so far + * - exitCode (integer, optional): Command exit code if complete + * - stderr (string, optional): Error output if any + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFcliGetPage implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final FcliRecordsCache cache; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + if (params == null || !params.has("cacheKey")) { + throw RpcMethodException.invalidParams("'cacheKey' parameter is required"); + } + + var cacheKey = params.get("cacheKey").asText(); + var offset = params.has("offset") ? params.get("offset").asInt(0) : 0; + var limit = params.has("limit") ? params.get("limit").asInt(100) : 100; + var wait = params.has("wait") && params.get("wait").asBoolean(false); + var waitTimeoutMs = params.has("waitTimeoutMs") ? params.get("waitTimeoutMs").asInt(30000) : 30000; + + if (cacheKey == null || cacheKey.isBlank()) { + throw RpcMethodException.invalidParams("'cacheKey' cannot be empty"); + } + + if (offset < 0) { + throw RpcMethodException.invalidParams("'offset' must be non-negative"); + } + + if (limit <= 0) { + throw RpcMethodException.invalidParams("'limit' must be greater than 0"); + } + + log.debug("Getting page: cacheKey={} offset={} limit={} wait={}", cacheKey, offset, limit, wait); + + try { + // If wait requested, wait for completion first + if (wait) { + var waitResult = cache.waitForCompletion(cacheKey, waitTimeoutMs); + if (waitResult != null) { + return buildCompletedResponse(waitResult, offset, limit, cacheKey); + } + } + + // Check if we have a cached complete result + var cached = cache.getCached(cacheKey); + if (cached != null) { + return buildCompletedResponse(cached, offset, limit, cacheKey); + } + + // Check if loading is in progress + var inProgress = cache.getInProgress(cacheKey); + if (inProgress != null) { + return buildInProgressResponse(inProgress, offset, limit); + } + + // Not found + return buildNotFoundResponse(cacheKey); + + } catch (Exception e) { + log.error("Error getting page: cacheKey={}", cacheKey, e); + throw RpcMethodException.internalError("Failed to get page: " + e.getMessage(), e); + } + } + + private ObjectNode buildCompletedResponse(FcliToolResult result, int offset, int limit, String cacheKey) { + var allRecords = result.getRecords(); + var totalRecords = allRecords != null ? allRecords.size() : 0; + + ObjectNode response = objectMapper.createObjectNode(); + response.put("status", result.getExitCode() == 0 ? "complete" : "error"); + response.put("cacheKey", cacheKey); + response.put("exitCode", result.getExitCode()); + + if (result.getStderr() != null && !result.getStderr().isBlank()) { + response.put("stderr", result.getStderr()); + } + + // Get the requested page + var endIndex = Math.min(offset + limit, totalRecords); + List pageRecords = offset >= totalRecords + ? List.of() + : allRecords.subList(offset, endIndex); + + ArrayNode recordsArray = response.putArray("records"); + pageRecords.forEach(recordsArray::add); + + // Pagination metadata + ObjectNode pagination = response.putObject("pagination"); + pagination.put("offset", offset); + pagination.put("limit", limit); + pagination.put("totalRecords", totalRecords); + pagination.put("totalPages", (int) Math.ceil((double) totalRecords / limit)); + pagination.put("hasMore", offset + limit < totalRecords); + pagination.put("complete", true); + if (offset + limit < totalRecords) { + pagination.put("nextOffset", offset + limit); + } + + response.put("loadedCount", totalRecords); + + return response; + } + + private ObjectNode buildInProgressResponse(FcliRecordsCache.InProgressEntry inProgress, int offset, int limit) { + var loadedRecords = inProgress.getRecordsSnapshot(); + var loadedCount = loadedRecords.size(); + + ObjectNode response = objectMapper.createObjectNode(); + response.put("status", inProgress.isCompleted() ? "complete" : "loading"); + response.put("cacheKey", inProgress.getCacheKey()); + response.put("loadedCount", loadedCount); + + if (inProgress.isCompleted()) { + response.put("exitCode", inProgress.getExitCode()); + if (inProgress.getStderr() != null && !inProgress.getStderr().isBlank()) { + response.put("stderr", inProgress.getStderr()); + } + } + + // Return available records within requested range + var endIndex = Math.min(offset + limit, loadedCount); + List pageRecords = offset >= loadedCount + ? List.of() + : loadedRecords.subList(offset, endIndex); + + ArrayNode recordsArray = response.putArray("records"); + pageRecords.forEach(recordsArray::add); + + // Pagination metadata (partial) + ObjectNode pagination = response.putObject("pagination"); + pagination.put("offset", offset); + pagination.put("limit", limit); + pagination.put("hasMore", loadedCount > offset + limit || !inProgress.isCompleted()); + pagination.put("complete", inProgress.isCompleted()); + if (loadedCount > offset + limit) { + pagination.put("nextOffset", offset + limit); + } + pagination.put("guidance", "Collection in progress. Call again with wait=true to wait for completion, or poll periodically."); + + return response; + } + + private ObjectNode buildNotFoundResponse(String cacheKey) { + ObjectNode response = objectMapper.createObjectNode(); + response.put("status", "not_found"); + response.put("cacheKey", cacheKey); + response.put("message", "No cached result or in-progress collection found for this cacheKey. Use fcli.executeAsync to start a new collection."); + response.putArray("records"); + return response; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerListMethods.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerListMethods.java index f2096e41f9..bdbf7451e7 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerListMethods.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerListMethods.java @@ -40,7 +40,11 @@ public final class RpcMethodHandlerListMethods implements IRpcMethodHandler { private final Map methodHandlers; private static final Map METHOD_DESCRIPTIONS = Map.of( - "fcli.execute", "Execute an fcli command and return structured results or stdout", + "fcli.execute", "Execute an fcli command synchronously and return structured results or stdout", + "fcli.executeAsync", "Start async fcli command execution, returns cacheKey for retrieving results", + "fcli.getPage", "Retrieve a page of results from cache by cacheKey (from fcli.executeAsync)", + "fcli.cancelCollection", "Cancel an in-progress async collection by cacheKey", + "fcli.clearCache", "Clear cache entries (specific cacheKey or all)", "fcli.listCommands", "List available fcli commands with optional filtering", "fcli.version", "Get fcli version information", "rpc.listMethods", "List available RPC methods" diff --git a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties index 9ed037c3fc..d1d5045a48 100644 --- a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties +++ b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties @@ -115,13 +115,34 @@ fcli.util.rpc-server.start.usage.description = The fcli JSON-RPC server provides programmatic access from IDE plugins.%n%n\ The server reads JSON-RPC requests from stdin and writes responses to stdout, one JSON object per line.%n%n\ Available RPC methods:%n\ - %n - fcli.execute: Execute any fcli command and return structured results\ + %n - fcli.execute: Execute an fcli command synchronously and return structured results\ %n Parameters:\ %n - command (string, required): The fcli command to execute (e.g., "ssc appversion list")\ %n - collectRecords (boolean, optional): If true, collect structured records instead of stdout\ %n - offset (integer, optional): For paging, the offset to start from (default: 0)\ %n - limit (integer, optional): For paging, the maximum number of records (default: 100)\ %n\ + %n - fcli.executeAsync: Start async command execution, returns cacheKey for retrieving results\ + %n Parameters:\ + %n - command (string, required): The fcli command to execute\ + %n Returns: cacheKey to use with fcli.getPage\ + %n\ + %n - fcli.getPage: Retrieve a page of results from cache\ + %n Parameters:\ + %n - cacheKey (string, required): Cache key from fcli.executeAsync\ + %n - offset (integer, optional): Start offset (default: 0)\ + %n - limit (integer, optional): Max records per page (default: 100)\ + %n - wait (boolean, optional): Wait for completion if still loading (default: false)\ + %n - waitTimeoutMs (integer, optional): Max wait time in ms (default: 30000)\ + %n\ + %n - fcli.cancelCollection: Cancel an in-progress async collection\ + %n Parameters:\ + %n - cacheKey (string, required): Cache key to cancel\ + %n\ + %n - fcli.clearCache: Clear cache entries\ + %n Parameters:\ + %n - cacheKey (string, optional): Specific key to clear, or omit to clear all\ + %n\ %n - fcli.listCommands: List available fcli commands with optional filtering\ %n Parameters:\ %n - module (string, optional): Filter by module (e.g., "ssc", "fod")\ @@ -134,6 +155,8 @@ fcli.util.rpc-server.start.usage.description = The fcli JSON-RPC server provides %n - rpc.listMethods: List available RPC methods\ %n Parameters: none\ %n%n\ + For commands that return large datasets (e.g., issue lists), use fcli.executeAsync followed by \ + fcli.getPage to efficiently retrieve paged results with background loading and caching.%n%n\ Example IDE plugin configuration (VS Code settings.json style):%n\ %n{\ %n "fortify.fcli.path": "/path/to/fcli",\ diff --git a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java b/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java index d8902462c8..4e4c31fac9 100644 --- a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java +++ b/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java @@ -272,4 +272,131 @@ void shouldReturnErrorForListCommandsWithoutAppContext() throws Exception { // but the important thing is that it doesn't crash assertTrue(node.has("error") || node.has("result")); } + + @Test + void shouldReturnCacheKeyForExecuteAsync() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.executeAsync\",\"params\":{\"command\":\"util sample-data list\"},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("result")); + assertNull(node.get("error")); + + var result = node.get("result"); + assertTrue(result.has("cacheKey")); + assertNotNull(result.get("cacheKey").asText()); + assertEquals("started", result.get("status").asText()); + } + + @Test + void shouldReturnInvalidParamsForExecuteAsyncWithoutCommand() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.executeAsync\",\"params\":{},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + } + + @Test + void shouldReturnNotFoundForGetPageWithInvalidCacheKey() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.getPage\",\"params\":{\"cacheKey\":\"non-existent-key\"},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("result")); + assertEquals("not_found", node.get("result").get("status").asText()); + } + + @Test + void shouldReturnInvalidParamsForGetPageWithoutCacheKey() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.getPage\",\"params\":{},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + } + + @Test + void shouldHandleCancelCollectionForNonExistentKey() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.cancelCollection\",\"params\":{\"cacheKey\":\"non-existent-key\"},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("result")); + assertEquals(false, node.get("result").get("success").asBoolean()); + } + + @Test + void shouldHandleClearCacheAll() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.clearCache\",\"params\":{},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("result")); + assertEquals(true, node.get("result").get("success").asBoolean()); + assertNotNull(node.get("result").get("stats")); + } + + @Test + void shouldListAllNewMethodsInRpcListMethods() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"rpc.listMethods\",\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("result")); + + var methods = node.get("result").get("methods"); + assertTrue(methods.isArray()); + // Verify minimum expected methods - don't hardcode exact count for maintainability + assertTrue(methods.size() >= 8, "Should have at least 8 methods including async ones"); + + // Verify new methods are present + boolean hasExecuteAsync = false; + boolean hasGetPage = false; + boolean hasCancelCollection = false; + boolean hasClearCache = false; + + for (var method : methods) { + String name = method.get("name").asText(); + if ("fcli.executeAsync".equals(name)) hasExecuteAsync = true; + if ("fcli.getPage".equals(name)) hasGetPage = true; + if ("fcli.cancelCollection".equals(name)) hasCancelCollection = true; + if ("fcli.clearCache".equals(name)) hasClearCache = true; + } + + assertTrue(hasExecuteAsync, "fcli.executeAsync method should be present"); + assertTrue(hasGetPage, "fcli.getPage method should be present"); + assertTrue(hasCancelCollection, "fcli.cancelCollection method should be present"); + assertTrue(hasClearCache, "fcli.clearCache method should be present"); + } } From 94a279250d9d81f1e70a9361735b2206ba3b03f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Nov 2025 14:00:47 +0000 Subject: [PATCH 5/7] Add product-specific session methods and improve security - Add fcli.ssc.login, fcli.ssc.logout for SSC sessions - Add fcli.fod.login, fcli.fod.logout for FoD sessions - Auto-logout all sessions when RPC server terminates - Auto-add session options to commands based on product type - Remove paging from fcli.execute (use async methods for large datasets) - Improve argument quoting to prevent injection issues - Fix logoutAll to avoid duplicate logout attempts - Update documentation and tests Co-authored-by: rsenden <8635138+rsenden@users.noreply.github.com> --- .../util/_common/helper/FcliRecordsCache.java | 15 +- .../util/_common/helper/FcliRunnerHelper.java | 55 ++- .../rpc_server/helper/rpc/JsonRpcServer.java | 19 +- .../rpc/RpcMethodHandlerFcliExecute.java | 65 +--- .../helper/rpc/RpcMethodHandlerFodLogin.java | 115 +++++++ .../helper/rpc/RpcMethodHandlerFodLogout.java | 47 +++ .../rpc/RpcMethodHandlerListMethods.java | 34 +- .../helper/rpc/RpcMethodHandlerSscLogin.java | 118 +++++++ .../helper/rpc/RpcMethodHandlerSscLogout.java | 47 +++ .../helper/rpc/RpcSessionManager.java | 321 ++++++++++++++++++ .../cli/util/i18n/UtilMessages.properties | 65 ++-- .../rpc_server/unit/JsonRpcServerTest.java | 103 +++++- 12 files changed, 911 insertions(+), 93 deletions(-) create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFodLogin.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFodLogout.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogin.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogout.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcSessionManager.java diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRecordsCache.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRecordsCache.java index 025d32d5f7..073fbfad97 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRecordsCache.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRecordsCache.java @@ -23,6 +23,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; +import java.util.function.Function; import com.fasterxml.jackson.databind.JsonNode; @@ -39,6 +40,7 @@ * - Background async loading with partial result access * - Cancel support for long-running collections * - Thread-safe concurrent access + * - Support for session options through option resolver * * @author Ruud Senden */ @@ -53,6 +55,7 @@ public class FcliRecordsCache { private final Map cache; private final Map inProgress = new ConcurrentHashMap<>(); private final ExecutorService backgroundExecutor; + private Function> optionResolver; public FcliRecordsCache() { this(DEFAULT_MAX_ENTRIES, DEFAULT_TTL, DEFAULT_BG_THREADS); @@ -76,6 +79,13 @@ protected boolean removeEldestEntry(Map.Entry eldest) { log.info("Initialized FcliRecordsCache: maxEntries={} ttl={}ms bgThreads={}", maxEntries, ttlMillis, bgThreads); } + /** + * Set a function to resolve default options for commands (e.g., session options). + */ + public void setOptionResolver(Function> resolver) { + this.optionResolver = resolver; + } + /** * Get cached result, or start background collection if not cached. * Returns null if result is already cached (caller should use getCached). @@ -118,13 +128,16 @@ private InProgressEntry startNewBackgroundCollection(String cacheKey, String com } private CompletableFuture buildCollectionFuture(InProgressEntry entry, String command) { + // Resolve options before starting async execution + var defaultOptions = optionResolver != null ? optionResolver.apply(command) : null; + return CompletableFuture.supplyAsync(() -> { var records = entry.getRecords(); var result = FcliRunnerHelper.collectRecords(command, record -> { if (!Thread.currentThread().isInterrupted()) { records.add(record); } - }); + }, defaultOptions); if (Thread.currentThread().isInterrupted()) { return null; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRunnerHelper.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRunnerHelper.java index 1187e93668..ba27f6a4c9 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRunnerHelper.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRunnerHelper.java @@ -13,6 +13,7 @@ package com.fortify.cli.util._common.helper; import java.util.ArrayList; +import java.util.Map; import java.util.function.Consumer; import com.fasterxml.jackson.databind.JsonNode; @@ -33,33 +34,64 @@ public class FcliRunnerHelper { * Execute a command and collect stdout output. */ public static Result collectStdout(String fullCmd) { - return FcliCommandExecutorFactory.builder() + return collectStdout(fullCmd, null); + } + + /** + * Execute a command and collect stdout output with default options. + */ + public static Result collectStdout(String fullCmd, Map defaultOptions) { + var builder = FcliCommandExecutorFactory.builder() .cmd(fullCmd) .stdoutOutputType(OutputType.collect) .stderrOutputType(OutputType.collect) - .onFail(r -> {}) - .build().create().execute(); + .onFail(r -> {}); + + if (defaultOptions != null) { + builder.defaultOptionsIfNotPresent(defaultOptions); + } + + return builder.build().create().execute(); } /** * Execute a command and collect structured records. */ public static Result collectRecords(String fullCmd, Consumer recordConsumer) { - return FcliCommandExecutorFactory.builder() + return collectRecords(fullCmd, recordConsumer, null); + } + + /** + * Execute a command and collect structured records with default options. + */ + public static Result collectRecords(String fullCmd, Consumer recordConsumer, Map defaultOptions) { + var builder = FcliCommandExecutorFactory.builder() .cmd(fullCmd) .stdoutOutputType(OutputType.suppress) .stderrOutputType(OutputType.collect) .recordConsumer(recordConsumer) - .onFail(r -> {}) - .build().create().execute(); + .onFail(r -> {}); + + if (defaultOptions != null) { + builder.defaultOptionsIfNotPresent(defaultOptions); + } + + return builder.build().create().execute(); } /** * Execute a command and return a FcliToolResult with all collected records. */ public static FcliToolResult collectRecordsAsResult(String fullCmd) { + return collectRecordsAsResult(fullCmd, null); + } + + /** + * Execute a command and return a FcliToolResult with all collected records and default options. + */ + public static FcliToolResult collectRecordsAsResult(String fullCmd, Map defaultOptions) { var records = new ArrayList(); - var result = collectRecords(fullCmd, records::add); + var result = collectRecords(fullCmd, records::add, defaultOptions); return FcliToolResult.fromRecords(result, records); } @@ -67,7 +99,14 @@ public static FcliToolResult collectRecordsAsResult(String fullCmd) { * Execute a command and return a FcliToolResult with stdout. */ public static FcliToolResult collectStdoutAsResult(String fullCmd) { - var result = collectStdout(fullCmd); + return collectStdoutAsResult(fullCmd, null); + } + + /** + * Execute a command and return a FcliToolResult with stdout and default options. + */ + public static FcliToolResult collectStdoutAsResult(String fullCmd, Map defaultOptions) { + var result = collectStdout(fullCmd, defaultOptions); return FcliToolResult.fromPlainText(result); } } diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java index ba942563cc..60f4d2781f 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java @@ -41,6 +41,7 @@ * - Is compatible with GraalVM native image compilation * - Processes requests synchronously (appropriate for stdio-based IDE integration) * - Includes caching for efficient paged access to large result sets + * - Manages product sessions (SSC, FoD) with automatic cleanup on shutdown * * @author Ruud Senden */ @@ -50,17 +51,23 @@ public final class JsonRpcServer { private final Map methodHandlers; private final AtomicBoolean running = new AtomicBoolean(false); private final FcliRecordsCache cache; + private final RpcSessionManager sessionManager; public JsonRpcServer(ObjectMapper objectMapper) { this.objectMapper = objectMapper; this.methodHandlers = new LinkedHashMap<>(); this.cache = new FcliRecordsCache(); + this.sessionManager = new RpcSessionManager(objectMapper); + + // Configure cache to use session manager for resolving session options + this.cache.setOptionResolver(sessionManager::getSessionOptionsForCommand); + registerDefaultMethods(); } private void registerDefaultMethods() { // Register built-in fcli methods - registerMethod("fcli.execute", new RpcMethodHandlerFcliExecute(objectMapper)); + registerMethod("fcli.execute", new RpcMethodHandlerFcliExecute(objectMapper, sessionManager)); registerMethod("fcli.executeAsync", new RpcMethodHandlerFcliExecuteAsync(objectMapper, cache)); registerMethod("fcli.getPage", new RpcMethodHandlerFcliGetPage(objectMapper, cache)); registerMethod("fcli.cancelCollection", new RpcMethodHandlerFcliCancelCollection(objectMapper, cache)); @@ -68,6 +75,14 @@ private void registerDefaultMethods() { registerMethod("fcli.listCommands", new RpcMethodHandlerFcliListCommands(objectMapper)); registerMethod("fcli.version", new RpcMethodHandlerFcliVersion(objectMapper)); registerMethod("rpc.listMethods", new RpcMethodHandlerListMethods(objectMapper, methodHandlers)); + + // Register product-specific session methods + for (var entry : sessionManager.getLoginHandlers().entrySet()) { + registerMethod("fcli." + entry.getKey() + ".login", entry.getValue()); + } + for (var entry : sessionManager.getLogoutHandlers().entrySet()) { + registerMethod("fcli." + entry.getKey() + ".logout", entry.getValue()); + } } /** @@ -109,6 +124,8 @@ public void start(InputStream input, OutputStream output) { log.error("Error in JSON-RPC server", e); } finally { running.set(false); + // Logout all sessions on shutdown + sessionManager.logoutAll(); cache.shutdown(); log.info("JSON-RPC server stopped"); } diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecute.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecute.java index 4bc10290ce..672ce42e12 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecute.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecute.java @@ -13,7 +13,6 @@ package com.fortify.cli.util.rpc_server.helper.rpc; import java.util.ArrayList; -import java.util.List; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -27,21 +26,21 @@ import lombok.extern.slf4j.Slf4j; /** - * RPC method handler for executing fcli commands. + * RPC method handler for executing fcli commands synchronously. * * Method: fcli.execute * Params: * - command (string, required): The fcli command to execute (e.g., "ssc appversion list") * - collectRecords (boolean, optional): If true, collect structured records instead of stdout - * - offset (integer, optional): For paging, the offset to start from (default: 0) - * - limit (integer, optional): For paging, the maximum number of records (default: 100) * * Returns: * - exitCode (integer): The command exit code - * - records (array, optional): Array of record objects if collectRecords=true + * - records (array, optional): Array of ALL record objects if collectRecords=true * - stdout (string, optional): Standard output if collectRecords=false * - stderr (string): Standard error output - * - pagination (object, optional): Pagination info for paged results + * + * Note: This method returns ALL records without paging. For commands that may return + * large datasets (e.g., issue list), use fcli.executeAsync + fcli.getPage instead. * * @author Ruud Senden */ @@ -49,6 +48,7 @@ @RequiredArgsConstructor public final class RpcMethodHandlerFcliExecute implements IRpcMethodHandler { private final ObjectMapper objectMapper; + private final RpcSessionManager sessionManager; @Override public JsonNode execute(JsonNode params) throws RpcMethodException { @@ -58,27 +58,16 @@ public JsonNode execute(JsonNode params) throws RpcMethodException { var command = params.get("command").asText(); var collectRecords = params.has("collectRecords") && params.get("collectRecords").asBoolean(false); - var offset = params.has("offset") ? params.get("offset").asInt(0) : 0; - var limit = params.has("limit") ? params.get("limit").asInt(100) : 100; if (command == null || command.isBlank()) { throw RpcMethodException.invalidParams("'command' cannot be empty"); } - if (offset < 0) { - throw RpcMethodException.invalidParams("'offset' must be non-negative"); - } - - if (limit <= 0) { - throw RpcMethodException.invalidParams("'limit' must be greater than 0"); - } - - log.debug("Executing fcli command: {} (collectRecords={}, offset={}, limit={})", - command, collectRecords, offset, limit); + log.debug("Executing fcli command: {} (collectRecords={})", command, collectRecords); try { if (collectRecords) { - return executeWithRecords(command, offset, limit); + return executeWithRecords(command); } else { return executeWithStdout(command); } @@ -93,13 +82,14 @@ private JsonNode executeWithStdout(String command) { .cmd(command) .stdoutOutputType(OutputType.collect) .stderrOutputType(OutputType.collect) + .defaultOptionsIfNotPresent(sessionManager.getSessionOptionsForCommand(command)) .onFail(r -> {}) .build().create().execute(); - return buildResponse(result, null, null); + return buildResponse(result, null); } - private JsonNode executeWithRecords(String command, int offset, int limit) { + private JsonNode executeWithRecords(String command) { var allRecords = new ArrayList(); var result = FcliCommandExecutorFactory.builder() @@ -107,27 +97,21 @@ private JsonNode executeWithRecords(String command, int offset, int limit) { .stdoutOutputType(OutputType.suppress) .stderrOutputType(OutputType.collect) .recordConsumer(allRecords::add) + .defaultOptionsIfNotPresent(sessionManager.getSessionOptionsForCommand(command)) .onFail(r -> {}) .build().create().execute(); - // Apply pagination - var totalRecords = allRecords.size(); - var endIndex = Math.min(offset + limit, totalRecords); - List pagedRecords = offset >= totalRecords - ? List.of() - : allRecords.subList(offset, endIndex); - - var pagination = buildPagination(offset, limit, totalRecords); - return buildResponse(result, pagedRecords, pagination); + return buildResponse(result, allRecords); } - private ObjectNode buildResponse(Result result, List records, ObjectNode pagination) { + private ObjectNode buildResponse(Result result, java.util.List records) { var response = objectMapper.createObjectNode(); response.put("exitCode", result.getExitCode()); if (records != null) { ArrayNode recordsArray = response.putArray("records"); records.forEach(recordsArray::add); + response.put("totalRecords", records.size()); } else { response.put("stdout", result.getOut()); } @@ -136,25 +120,6 @@ private ObjectNode buildResponse(Result result, List records, ObjectNo response.put("stderr", result.getErr()); } - if (pagination != null) { - response.set("pagination", pagination); - } - return response; } - - private ObjectNode buildPagination(int offset, int limit, int totalRecords) { - var pagination = objectMapper.createObjectNode(); - pagination.put("offset", offset); - pagination.put("limit", limit); - pagination.put("totalRecords", totalRecords); - pagination.put("totalPages", (int) Math.ceil((double) totalRecords / limit)); - pagination.put("hasMore", offset + limit < totalRecords); - - if (offset + limit < totalRecords) { - pagination.put("nextOffset", offset + limit); - } - - return pagination; - } } diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFodLogin.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFodLogin.java new file mode 100644 index 0000000000..5747a157a4 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFodLogin.java @@ -0,0 +1,115 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.util.rpc_server.helper.rpc.RpcSessionManager.ProductType; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for FoD session login. + * + * Method: fcli.fod.login + * Params: + * - url (string, required): FoD URL (e.g., "https://ams.fortify.com") + * - client-id (string, optional): API client ID for client credentials auth + * - client-secret (string, optional): API client secret for client credentials auth + * - user (string, optional): Username for user/password auth + * - password (string, optional): Password for user/password auth + * - tenant (string, optional): Tenant name (required for user/password auth) + * - insecure (boolean, optional): Allow insecure connections + * + * Authentication requires either (client-id + client-secret) or (user + password + tenant). + * + * Returns: + * - success (boolean): Whether login was successful + * - sessionName (string): The session name created + * - product (string): "fod" + * - message (string): Status message + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFodLogin implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final RpcSessionManager sessionManager; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + if (params == null || !params.has("url")) { + throw RpcMethodException.invalidParams("'url' parameter is required"); + } + + var loginArgs = buildLoginArgs(params); + + log.debug("FoD login with args: {}", loginArgs.replaceAll("(--password|--client-secret)\\s+\\S+", "$1 ***")); + + return sessionManager.executeLogin(ProductType.FOD, loginArgs); + } + + private String buildLoginArgs(JsonNode params) throws RpcMethodException { + var sb = new StringBuilder(); + + // URL is required + sb.append("--url ").append(quoteValue(params.get("url").asText())).append(" "); + + // Authentication - at least one method required + boolean hasAuth = false; + + if (params.has("client-id") && params.has("client-secret")) { + sb.append("--client-id ").append(quoteValue(params.get("client-id").asText())).append(" "); + sb.append("--client-secret ").append(quoteValue(params.get("client-secret").asText())).append(" "); + hasAuth = true; + } + + if (params.has("user") && params.has("password")) { + if (!params.has("tenant")) { + throw RpcMethodException.invalidParams( + "FoD user/password login requires 'tenant' parameter"); + } + sb.append("--user ").append(quoteValue(params.get("user").asText())).append(" "); + sb.append("--password ").append(quoteValue(params.get("password").asText())).append(" "); + sb.append("--tenant ").append(quoteValue(params.get("tenant").asText())).append(" "); + hasAuth = true; + } + + if (!hasAuth) { + throw RpcMethodException.invalidParams( + "FoD login requires either (client-id + client-secret) or (user + password + tenant)"); + } + + // Optional parameters + if (params.has("insecure") && params.get("insecure").asBoolean(false)) { + sb.append("-k "); + } + + return sb.toString().trim(); + } + + /** + * Quote a value for use in fcli command arguments. + * Always quotes the value to ensure special characters are handled correctly. + * The value is placed in double quotes with any internal quotes escaped. + */ + private String quoteValue(String value) { + if (value == null || value.isEmpty()) { + return "\"\""; + } + // Escape any double quotes in the value and wrap in double quotes + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFodLogout.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFodLogout.java new file mode 100644 index 0000000000..8cde43f8f2 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFodLogout.java @@ -0,0 +1,47 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.util.rpc_server.helper.rpc.RpcSessionManager.ProductType; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for FoD session logout. + * + * Method: fcli.fod.logout + * Params: none required + * + * Returns: + * - success (boolean): Whether logout was successful + * - sessionName (string): The session name that was logged out + * - product (string): "fod" + * - message (string): Status message + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFodLogout implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final RpcSessionManager sessionManager; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + log.debug("FoD logout"); + return sessionManager.executeLogout(ProductType.FOD); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerListMethods.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerListMethods.java index bdbf7451e7..30d3b7b539 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerListMethods.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerListMethods.java @@ -12,6 +12,7 @@ */ package com.fortify.cli.util.rpc_server.helper.rpc; +import java.util.HashMap; import java.util.Map; import com.fasterxml.jackson.databind.JsonNode; @@ -39,16 +40,29 @@ public final class RpcMethodHandlerListMethods implements IRpcMethodHandler { private final ObjectMapper objectMapper; private final Map methodHandlers; - private static final Map METHOD_DESCRIPTIONS = Map.of( - "fcli.execute", "Execute an fcli command synchronously and return structured results or stdout", - "fcli.executeAsync", "Start async fcli command execution, returns cacheKey for retrieving results", - "fcli.getPage", "Retrieve a page of results from cache by cacheKey (from fcli.executeAsync)", - "fcli.cancelCollection", "Cancel an in-progress async collection by cacheKey", - "fcli.clearCache", "Clear cache entries (specific cacheKey or all)", - "fcli.listCommands", "List available fcli commands with optional filtering", - "fcli.version", "Get fcli version information", - "rpc.listMethods", "List available RPC methods" - ); + private static final Map METHOD_DESCRIPTIONS = new HashMap<>(); + + static { + // Core execution methods + METHOD_DESCRIPTIONS.put("fcli.execute", "Execute an fcli command synchronously and return all results"); + METHOD_DESCRIPTIONS.put("fcli.executeAsync", "Start async fcli command execution, returns cacheKey for paged retrieval"); + METHOD_DESCRIPTIONS.put("fcli.getPage", "Retrieve a page of results from cache by cacheKey"); + METHOD_DESCRIPTIONS.put("fcli.cancelCollection", "Cancel an in-progress async collection by cacheKey"); + METHOD_DESCRIPTIONS.put("fcli.clearCache", "Clear cache entries (specific cacheKey or all)"); + + // Info methods + METHOD_DESCRIPTIONS.put("fcli.listCommands", "List available fcli commands with optional filtering"); + METHOD_DESCRIPTIONS.put("fcli.version", "Get fcli version information"); + METHOD_DESCRIPTIONS.put("rpc.listMethods", "List available RPC methods"); + + // SSC session methods + METHOD_DESCRIPTIONS.put("fcli.ssc.login", "Login to SSC (params: url, user+password or token or ci-token)"); + METHOD_DESCRIPTIONS.put("fcli.ssc.logout", "Logout from SSC session"); + + // FoD session methods + METHOD_DESCRIPTIONS.put("fcli.fod.login", "Login to FoD (params: url, client-id+client-secret or user+password+tenant)"); + METHOD_DESCRIPTIONS.put("fcli.fod.logout", "Logout from FoD session"); + } @Override public JsonNode execute(JsonNode params) throws RpcMethodException { diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogin.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogin.java new file mode 100644 index 0000000000..a469dde1eb --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogin.java @@ -0,0 +1,118 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.util.rpc_server.helper.rpc.RpcSessionManager.ProductType; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for SSC session login. + * + * Method: fcli.ssc.login + * Params: + * - url (string, required): SSC URL + * - user (string, optional): Username for user/password auth + * - password (string, optional): Password for user/password auth + * - token (string, optional): UnifiedLoginToken for token-based auth + * - ci-token (string, optional): CIToken for CI/CD integration + * - expire-in (string, optional): Token expiration time (e.g., "1d", "8h") + * - insecure (boolean, optional): Allow insecure connections + * + * At least one auth method must be provided: (user+password), token, or ci-token. + * + * Returns: + * - success (boolean): Whether login was successful + * - sessionName (string): The session name created + * - product (string): "ssc" + * - message (string): Status message + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerSscLogin implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final RpcSessionManager sessionManager; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + if (params == null || !params.has("url")) { + throw RpcMethodException.invalidParams("'url' parameter is required"); + } + + var loginArgs = buildLoginArgs(params); + + log.debug("SSC login with args: {}", loginArgs.replaceAll("(--password|--token|--ci-token)\\s+\\S+", "$1 ***")); + + return sessionManager.executeLogin(ProductType.SSC, loginArgs); + } + + private String buildLoginArgs(JsonNode params) throws RpcMethodException { + var sb = new StringBuilder(); + + // URL is required + sb.append("--url ").append(quoteValue(params.get("url").asText())).append(" "); + + // Authentication - at least one method required + boolean hasAuth = false; + + if (params.has("user") && params.has("password")) { + sb.append("--user ").append(quoteValue(params.get("user").asText())).append(" "); + sb.append("--password ").append(quoteValue(params.get("password").asText())).append(" "); + hasAuth = true; + } + + if (params.has("token")) { + sb.append("--token ").append(quoteValue(params.get("token").asText())).append(" "); + hasAuth = true; + } + + if (params.has("ci-token")) { + sb.append("--ci-token ").append(quoteValue(params.get("ci-token").asText())).append(" "); + hasAuth = true; + } + + if (!hasAuth) { + throw RpcMethodException.invalidParams( + "SSC login requires one of: (user + password), token, or ci-token"); + } + + // Optional parameters + if (params.has("expire-in")) { + sb.append("--expire-in ").append(params.get("expire-in").asText()).append(" "); + } + + if (params.has("insecure") && params.get("insecure").asBoolean(false)) { + sb.append("-k "); + } + + return sb.toString().trim(); + } + + /** + * Quote a value for use in fcli command arguments. + * Always quotes the value to ensure special characters are handled correctly. + * The value is placed in double quotes with any internal quotes escaped. + */ + private String quoteValue(String value) { + if (value == null || value.isEmpty()) { + return "\"\""; + } + // Escape any double quotes in the value and wrap in double quotes + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogout.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogout.java new file mode 100644 index 0000000000..25b33917ec --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogout.java @@ -0,0 +1,47 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.util.rpc_server.helper.rpc.RpcSessionManager.ProductType; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for SSC session logout. + * + * Method: fcli.ssc.logout + * Params: none required + * + * Returns: + * - success (boolean): Whether logout was successful + * - sessionName (string): The session name that was logged out + * - product (string): "ssc" + * - message (string): Status message + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerSscLogout implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final RpcSessionManager sessionManager; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + log.debug("SSC logout"); + return sessionManager.executeLogout(ProductType.SSC); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcSessionManager.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcSessionManager.java new file mode 100644 index 0000000000..0a8d61226d --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcSessionManager.java @@ -0,0 +1,321 @@ +/* + * Copyright 2021-2025 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.util.rpc_server.helper.rpc; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.cli.util.FcliCommandExecutorFactory; +import com.fortify.cli.common.util.OutputHelper.OutputType; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Manages sessions for the RPC server. This class: + * - Creates unique session names for each product type (SSC, FoD, etc.) + * - Tracks which sessions have been created by the RPC server + * - Auto-discovers which session type is needed for a command + * - Provides session options to be added to commands + * - Logs out all sessions when the server shuts down + * + * The architecture is extensible: new products can be added by registering + * additional product handlers. + * + * @author Ruud Senden + */ +@Slf4j +public final class RpcSessionManager { + + /** + * Supported product types and their session option names. + */ + public enum ProductType { + SSC("--ssc-session", "ssc", "ssc session"), + FOD("--fod-session", "fod", "fod session"), + SC_SAST("--ssc-session", "sc-sast", "ssc session"), // SC-SAST uses SSC session + SC_DAST("--ssc-session", "sc-dast", "ssc session"); // SC-DAST uses SSC session + + @Getter private final String sessionOption; + @Getter private final String commandPrefix; + @Getter private final String sessionCommandPrefix; + + ProductType(String sessionOption, String commandPrefix, String sessionCommandPrefix) { + this.sessionOption = sessionOption; + this.commandPrefix = commandPrefix; + this.sessionCommandPrefix = sessionCommandPrefix; + } + + /** + * Determine the product type from a command string. + */ + public static ProductType fromCommand(String command) { + if (command == null) return null; + var normalizedCmd = command.toLowerCase().replaceFirst("^fcli\\s+", "").trim(); + + // Check specific product prefixes + if (normalizedCmd.startsWith("ssc ")) return SSC; + if (normalizedCmd.startsWith("fod ")) return FOD; + if (normalizedCmd.startsWith("sc-sast ")) return SC_SAST; + if (normalizedCmd.startsWith("sc-dast ")) return SC_DAST; + + return null; + } + + /** + * Get the actual session type for this product (e.g., SC-SAST uses SSC session). + */ + public ProductType getSessionType() { + return switch (this) { + case SC_SAST, SC_DAST -> SSC; + default -> this; + }; + } + } + + private final ObjectMapper objectMapper; + + // Unique ID for this RPC server instance + private final String instanceId = UUID.randomUUID().toString().substring(0, 8); + + // Session names created by this RPC server (product type -> session name) + private final Map sessionNames = new HashMap<>(); + + // Set of sessions that we've successfully logged in (need to logout on shutdown) + private final Set activeSessions = new LinkedHashSet<>(); + + // Registry of RPC method handlers for session login (product -> handler) + private final Map loginHandlers = new LinkedHashMap<>(); + + // Registry of RPC method handlers for session logout (product -> handler) + private final Map logoutHandlers = new LinkedHashMap<>(); + + public RpcSessionManager(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + registerDefaultHandlers(); + } + + private void registerDefaultHandlers() { + // Register SSC session handlers + registerLoginHandler("ssc", new RpcMethodHandlerSscLogin(objectMapper, this)); + registerLogoutHandler("ssc", new RpcMethodHandlerSscLogout(objectMapper, this)); + + // Register FoD session handlers + registerLoginHandler("fod", new RpcMethodHandlerFodLogin(objectMapper, this)); + registerLogoutHandler("fod", new RpcMethodHandlerFodLogout(objectMapper, this)); + } + + /** + * Register a login handler for a product. + */ + public void registerLoginHandler(String product, IRpcMethodHandler handler) { + loginHandlers.put(product.toLowerCase(), handler); + } + + /** + * Register a logout handler for a product. + */ + public void registerLogoutHandler(String product, IRpcMethodHandler handler) { + logoutHandlers.put(product.toLowerCase(), handler); + } + + /** + * Get all login handlers (for registering RPC methods). + */ + public Map getLoginHandlers() { + return Map.copyOf(loginHandlers); + } + + /** + * Get all logout handlers (for registering RPC methods). + */ + public Map getLogoutHandlers() { + return Map.copyOf(logoutHandlers); + } + + /** + * Get the session name for a product type, creating one if needed. + */ + public String getSessionName(ProductType productType) { + // Use the actual session type (e.g., SC-SAST uses SSC session) + var sessionType = productType.getSessionType(); + return sessionNames.computeIfAbsent(sessionType, + pt -> "rpc-" + instanceId + "-" + pt.name().toLowerCase()); + } + + /** + * Get session options to add to a command, based on the command prefix. + * Returns empty map if the command doesn't need a session or if we don't have one. + */ + public Map getSessionOptionsForCommand(String command) { + var productType = ProductType.fromCommand(command); + if (productType == null) { + return Map.of(); + } + + // Use the actual session type + var sessionType = productType.getSessionType(); + + // If we have an active session for this product type, add the option + if (activeSessions.contains(sessionType)) { + var sessionName = sessionNames.get(sessionType); + if (sessionName != null) { + return Map.of(productType.getSessionOption(), sessionName); + } + } + + return Map.of(); + } + + /** + * Execute login command and track the session. + */ + public JsonNode executeLogin(ProductType productType, String loginArgs) { + var sessionName = getSessionName(productType); + var loginCmd = buildLoginCommand(productType, sessionName, loginArgs); + + log.info("RPC session login: {} (session: {})", productType, sessionName); + + var result = FcliCommandExecutorFactory.builder() + .cmd(loginCmd) + .stdoutOutputType(OutputType.collect) + .stderrOutputType(OutputType.collect) + .onFail(r -> {}) + .build().create().execute(); + + ObjectNode response = objectMapper.createObjectNode(); + response.put("product", productType.name().toLowerCase().replace("_", "-")); + response.put("sessionName", sessionName); + + if (result.getExitCode() == 0) { + activeSessions.add(productType.getSessionType()); + response.put("success", true); + response.put("message", "Successfully logged in to " + productType); + log.info("RPC session login successful: {}", sessionName); + } else { + response.put("success", false); + response.put("message", "Login failed: " + result.getErr()); + response.put("stderr", result.getErr()); + log.error("RPC session login failed: {} - {}", sessionName, result.getErr()); + } + + return response; + } + + /** + * Execute logout command for a product. + */ + public JsonNode executeLogout(ProductType productType) { + var sessionType = productType.getSessionType(); + var sessionName = sessionNames.get(sessionType); + + ObjectNode response = objectMapper.createObjectNode(); + response.put("product", productType.name().toLowerCase().replace("_", "-")); + + if (sessionName == null || !activeSessions.contains(sessionType)) { + response.put("success", true); + response.put("message", "No active session to logout"); + return response; + } + + var logoutCmd = buildLogoutCommand(productType, sessionName); + + log.info("RPC session logout: {} (session: {})", productType, sessionName); + + var result = FcliCommandExecutorFactory.builder() + .cmd(logoutCmd) + .stdoutOutputType(OutputType.suppress) + .stderrOutputType(OutputType.collect) + .onFail(r -> {}) + .build().create().execute(); + + response.put("sessionName", sessionName); + + if (result.getExitCode() == 0) { + activeSessions.remove(sessionType); + response.put("success", true); + response.put("message", "Successfully logged out from " + productType); + log.info("RPC session logout successful: {}", sessionName); + } else { + response.put("success", false); + response.put("message", "Logout failed: " + result.getErr()); + log.warn("RPC session logout failed: {} - {}", sessionName, result.getErr()); + } + + return response; + } + + /** + * Logout from all sessions created by this RPC server. + * Called on server shutdown. + */ + public void logoutAll() { + log.info("Logging out all RPC sessions..."); + + // Iterate through activeSessions directly to avoid duplicate logout attempts + // (e.g., SC_SAST and SC_DAST share SSC session type) + for (var sessionType : Set.copyOf(activeSessions)) { + try { + executeLogout(sessionType); + } catch (Exception e) { + log.warn("Failed to logout session for {}: {}", sessionType, e.getMessage()); + } + } + + activeSessions.clear(); + sessionNames.clear(); + log.info("All RPC sessions logged out"); + } + + /** + * Get list of active sessions as JSON. + */ + public JsonNode getActiveSessions() { + ArrayNode sessions = objectMapper.createArrayNode(); + for (var productType : activeSessions) { + ObjectNode session = objectMapper.createObjectNode(); + session.put("product", productType.name().toLowerCase().replace("_", "-")); + session.put("sessionName", sessionNames.get(productType)); + sessions.add(session); + } + return sessions; + } + + /** + * Check if a session is active for a product type. + */ + public boolean hasActiveSession(ProductType productType) { + return activeSessions.contains(productType.getSessionType()); + } + + private String buildLoginCommand(ProductType productType, String sessionName, String loginArgs) { + // Session name is generated internally (rpc-{uuid}-{product}) and is safe + // loginArgs are pre-quoted by the login handlers + var baseCmd = productType.getSessionCommandPrefix() + " login"; + return String.format("%s %s %s", baseCmd, sessionName, loginArgs != null ? loginArgs : "").trim(); + } + + private String buildLogoutCommand(ProductType productType, String sessionName) { + // Session name is generated internally and is safe + var baseCmd = productType.getSessionCommandPrefix() + " logout"; + return String.format("%s %s", baseCmd, sessionName); + } +} diff --git a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties index d1d5045a48..027012e8ea 100644 --- a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties +++ b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties @@ -113,16 +113,46 @@ fcli.util.rpc-server.start.usage.description = The fcli JSON-RPC server provides for IDE plugins and other tools to interact with Fortify products through fcli. Unlike the MCP server which is \ designed for LLM integration, the RPC server exposes a smaller set of general-purpose methods suitable for \ programmatic access from IDE plugins.%n%n\ - The server reads JSON-RPC requests from stdin and writes responses to stdout, one JSON object per line.%n%n\ + The server reads JSON-RPC requests from stdin and writes responses to stdout, one JSON object per line. \ + Sessions are automatically logged out when the server terminates.%n%n\ Available RPC methods:%n\ - %n - fcli.execute: Execute an fcli command synchronously and return structured results\ + %n SESSION METHODS (per product):\ + %n\ + %n - fcli.ssc.login: Login to SSC\ + %n Parameters:\ + %n - url (string, required): SSC URL\ + %n - user (string): Username for user/password auth\ + %n - password (string): Password for user/password auth\ + %n - token (string): UnifiedLoginToken\ + %n - ci-token (string): CIToken for CI/CD\ + %n - expire-in (string): Token expiration (e.g., "1d", "8h")\ + %n - insecure (boolean): Allow insecure connections\ + %n Note: Requires one of (user+password), token, or ci-token\ + %n\ + %n - fcli.ssc.logout: Logout from SSC session\ + %n\ + %n - fcli.fod.login: Login to FoD\ + %n Parameters:\ + %n - url (string, required): FoD URL (e.g., "https://ams.fortify.com")\ + %n - client-id (string): API client ID\ + %n - client-secret (string): API client secret\ + %n - user (string): Username\ + %n - password (string): Password\ + %n - tenant (string): Tenant name (required for user/password)\ + %n - insecure (boolean): Allow insecure connections\ + %n Note: Requires either (client-id+client-secret) or (user+password+tenant)\ + %n\ + %n - fcli.fod.logout: Logout from FoD session\ + %n\ + %n EXECUTION METHODS:\ + %n\ + %n - fcli.execute: Execute an fcli command synchronously and return ALL results\ %n Parameters:\ %n - command (string, required): The fcli command to execute (e.g., "ssc appversion list")\ %n - collectRecords (boolean, optional): If true, collect structured records instead of stdout\ - %n - offset (integer, optional): For paging, the offset to start from (default: 0)\ - %n - limit (integer, optional): For paging, the maximum number of records (default: 100)\ + %n Note: For large datasets, use fcli.executeAsync + fcli.getPage instead\ %n\ - %n - fcli.executeAsync: Start async command execution, returns cacheKey for retrieving results\ + %n - fcli.executeAsync: Start async command execution, returns cacheKey for paged retrieval\ %n Parameters:\ %n - command (string, required): The fcli command to execute\ %n Returns: cacheKey to use with fcli.getPage\ @@ -143,6 +173,8 @@ fcli.util.rpc-server.start.usage.description = The fcli JSON-RPC server provides %n Parameters:\ %n - cacheKey (string, optional): Specific key to clear, or omit to clear all\ %n\ + %n INFO METHODS:\ + %n\ %n - fcli.listCommands: List available fcli commands with optional filtering\ %n Parameters:\ %n - module (string, optional): Filter by module (e.g., "ssc", "fod")\ @@ -155,20 +187,15 @@ fcli.util.rpc-server.start.usage.description = The fcli JSON-RPC server provides %n - rpc.listMethods: List available RPC methods\ %n Parameters: none\ %n%n\ - For commands that return large datasets (e.g., issue lists), use fcli.executeAsync followed by \ - fcli.getPage to efficiently retrieve paged results with background loading and caching.%n%n\ - Example IDE plugin configuration (VS Code settings.json style):%n\ - %n{\ - %n "fortify.fcli.path": "/path/to/fcli",\ - %n "fortify.rpc.args": ["util", "rpc-server", "start"]\ - %n}\ - %n%n\ - Example JSON-RPC request/response:%n\ - %nRequest: {"jsonrpc":"2.0","method":"fcli.version","id":1}\ - %nResponse: {"jsonrpc":"2.0","result":{"version":"x.y.z","buildDate":"..."},"id":1}\ - %n%n\ - Note: Like the MCP server, you'll need to have active fcli sessions for each product you want to \ - interact with. Login sessions must be created separately using 'fcli session login' commands. + Typical workflow:%n\ + 1. Call fcli.ssc.login or fcli.fod.login with credentials%n\ + 2. Execute commands via fcli.execute or fcli.executeAsync%n\ + 3. Session options are automatically added to commands%n\ + 4. Sessions are logged out automatically when RPC server terminates%n%n\ + Example JSON-RPC requests:%n\ + %n{"jsonrpc":"2.0","method":"fcli.ssc.login","params":{"url":"https://ssc.example.com","token":"mytoken"},"id":1}\ + %n{"jsonrpc":"2.0","method":"fcli.execute","params":{"command":"ssc appversion list","collectRecords":true},"id":2}\ + %n{"jsonrpc":"2.0","method":"fcli.ssc.logout","id":3} # fcli util sample-data fcli.util.sample-data.usage.header = (INTERNAL) Generate sample data diff --git a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java b/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java index 4e4c31fac9..716fa4b0e3 100644 --- a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java +++ b/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java @@ -159,9 +159,9 @@ void shouldReturnInvalidParamsForExecuteWithoutCommand() throws Exception { @Test void shouldReturnInvalidParamsForZeroLimit() throws Exception { - // Act + // Test limit validation in fcli.getPage String response = server.processRequest( - "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.execute\",\"params\":{\"command\":\"util sample-data list\",\"collectRecords\":true,\"limit\":0},\"id\":1}"); + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.getPage\",\"params\":{\"cacheKey\":\"test-key\",\"limit\":0},\"id\":1}"); // Assert assertNotNull(response); @@ -173,9 +173,9 @@ void shouldReturnInvalidParamsForZeroLimit() throws Exception { @Test void shouldReturnInvalidParamsForNegativeOffset() throws Exception { - // Act + // Test offset validation in fcli.getPage String response = server.processRequest( - "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.execute\",\"params\":{\"command\":\"util sample-data list\",\"collectRecords\":true,\"offset\":-5},\"id\":1}"); + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.getPage\",\"params\":{\"cacheKey\":\"test-key\",\"offset\":-5},\"id\":1}"); // Assert assertNotNull(response); @@ -399,4 +399,99 @@ void shouldListAllNewMethodsInRpcListMethods() throws Exception { assertTrue(hasCancelCollection, "fcli.cancelCollection method should be present"); assertTrue(hasClearCache, "fcli.clearCache method should be present"); } + + @Test + void shouldListSessionMethodsInRpcListMethods() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"rpc.listMethods\",\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("result")); + + var methods = node.get("result").get("methods"); + assertTrue(methods.isArray()); + // Verify we have at least 12 methods (8 core + 4 session methods) + assertTrue(methods.size() >= 12, "Should have at least 12 methods including session ones"); + + // Verify session methods are present + boolean hasSscLogin = false; + boolean hasSscLogout = false; + boolean hasFodLogin = false; + boolean hasFodLogout = false; + + for (var method : methods) { + String name = method.get("name").asText(); + if ("fcli.ssc.login".equals(name)) hasSscLogin = true; + if ("fcli.ssc.logout".equals(name)) hasSscLogout = true; + if ("fcli.fod.login".equals(name)) hasFodLogin = true; + if ("fcli.fod.logout".equals(name)) hasFodLogout = true; + } + + assertTrue(hasSscLogin, "fcli.ssc.login method should be present"); + assertTrue(hasSscLogout, "fcli.ssc.logout method should be present"); + assertTrue(hasFodLogin, "fcli.fod.login method should be present"); + assertTrue(hasFodLogout, "fcli.fod.logout method should be present"); + } + + @Test + void shouldReturnInvalidParamsForSscLoginWithoutUrl() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.ssc.login\",\"params\":{\"token\":\"test\"},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + assertTrue(node.get("error").get("message").asText().contains("url")); + } + + @Test + void shouldReturnInvalidParamsForSscLoginWithoutAuth() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.ssc.login\",\"params\":{\"url\":\"https://ssc.example.com\"},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + } + + @Test + void shouldReturnInvalidParamsForFodLoginWithoutUrl() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.fod.login\",\"params\":{\"client-id\":\"test\",\"client-secret\":\"test\"},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + assertTrue(node.get("error").get("message").asText().contains("url")); + } + + @Test + void shouldReturnInvalidParamsForFodLoginWithoutAuth() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.fod.login\",\"params\":{\"url\":\"https://ams.fortify.com\"},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + } } From 38cbca8b3f0aea24981f7d25c581246dd3e0a999 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Nov 2025 14:27:28 +0000 Subject: [PATCH 6/7] Update SSC login: remove ci-token, add client-auth-token and sc-sast-url Per review feedback: - Removed ci-token (equivalent to token) - Added client-auth-token for SC-SAST authentication - Added sc-sast-url for SC-SAST controller URL - insecure mode already supported for both login methods Co-authored-by: rsenden <8635138+rsenden@users.noreply.github.com> --- .../helper/rpc/RpcMethodHandlerSscLogin.java | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogin.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogin.java index a469dde1eb..e991f9f6d8 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogin.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogin.java @@ -28,11 +28,12 @@ * - user (string, optional): Username for user/password auth * - password (string, optional): Password for user/password auth * - token (string, optional): UnifiedLoginToken for token-based auth - * - ci-token (string, optional): CIToken for CI/CD integration + * - client-auth-token (string, optional): SC-SAST client auth token + * - sc-sast-url (string, optional): SC-SAST controller URL * - expire-in (string, optional): Token expiration time (e.g., "1d", "8h") * - insecure (boolean, optional): Allow insecure connections * - * At least one auth method must be provided: (user+password), token, or ci-token. + * At least one auth method must be provided: (user+password) or token. * * Returns: * - success (boolean): Whether login was successful @@ -56,7 +57,7 @@ public JsonNode execute(JsonNode params) throws RpcMethodException { var loginArgs = buildLoginArgs(params); - log.debug("SSC login with args: {}", loginArgs.replaceAll("(--password|--token|--ci-token)\\s+\\S+", "$1 ***")); + log.debug("SSC login with args: {}", loginArgs.replaceAll("(--password|--token|--client-auth-token)\\s+\\S+", "$1 ***")); return sessionManager.executeLogin(ProductType.SSC, loginArgs); } @@ -81,14 +82,9 @@ private String buildLoginArgs(JsonNode params) throws RpcMethodException { hasAuth = true; } - if (params.has("ci-token")) { - sb.append("--ci-token ").append(quoteValue(params.get("ci-token").asText())).append(" "); - hasAuth = true; - } - if (!hasAuth) { throw RpcMethodException.invalidParams( - "SSC login requires one of: (user + password), token, or ci-token"); + "SSC login requires one of: (user + password) or token"); } // Optional parameters @@ -96,6 +92,14 @@ private String buildLoginArgs(JsonNode params) throws RpcMethodException { sb.append("--expire-in ").append(params.get("expire-in").asText()).append(" "); } + if (params.has("client-auth-token")) { + sb.append("--client-auth-token ").append(quoteValue(params.get("client-auth-token").asText())).append(" "); + } + + if (params.has("sc-sast-url")) { + sb.append("--sc-sast-url ").append(quoteValue(params.get("sc-sast-url").asText())).append(" "); + } + if (params.has("insecure") && params.get("insecure").asBoolean(false)) { sb.append("-k "); } From 56386375f18da3d5f2dfb6c42fd9487da2923113 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 00:00:47 +0000 Subject: [PATCH 7/7] Update RPC server usage help with correct method signatures - Remove ci-token (now just use token for both UnifiedLoginToken and CIToken) - Add client-auth-token and sc-sast-url for SC-SAST authentication - Fix authentication note to reflect current implementation Co-authored-by: rsenden <8635138+rsenden@users.noreply.github.com> --- .../com/fortify/cli/util/i18n/UtilMessages.properties | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties index 027012e8ea..83e95e1d24 100644 --- a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties +++ b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties @@ -123,11 +123,12 @@ fcli.util.rpc-server.start.usage.description = The fcli JSON-RPC server provides %n - url (string, required): SSC URL\ %n - user (string): Username for user/password auth\ %n - password (string): Password for user/password auth\ - %n - token (string): UnifiedLoginToken\ - %n - ci-token (string): CIToken for CI/CD\ + %n - token (string): UnifiedLoginToken or CIToken\ + %n - client-auth-token (string, optional): SC-SAST client auth token\ + %n - sc-sast-url (string, optional): SC-SAST controller URL\ %n - expire-in (string): Token expiration (e.g., "1d", "8h")\ %n - insecure (boolean): Allow insecure connections\ - %n Note: Requires one of (user+password), token, or ci-token\ + %n Note: Requires one of (user+password) or token\ %n\ %n - fcli.ssc.logout: Logout from SSC session\ %n\