From 275f694af0ccb192569caef5ea0eb42282b7bfc1 Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Sun, 20 Sep 2026 21:16:36 +0800 Subject: [PATCH 1/3] feat: align Comfy CLI/MCP surface and harden subprocess lifecycle --- .github/workflows/ci.yml | 14 +- .../io/github/easy4j/comfy/ComfyClient.java | 175 +--- .../easy4j/comfy/ComfyClientConfig.java | 66 +- .../github/easy4j/comfy/ComfyException.java | 40 +- .../io/github/easy4j/comfy/cli/ComfyCli.java | 682 ++++++-------- .../easy4j/comfy/cli/ComfyCliExecutor.java | 236 +++-- .../easy4j/comfy/cli/ComfyCliResult.java | 55 +- .../easy4j/comfy/mcp/ComfyMcpCallResult.java | 49 +- .../easy4j/comfy/mcp/ComfyMcpClient.java | 838 ++++++++++++------ .../easy4j/comfy/mcp/ComfyMcpConfig.java | 81 +- .../easy4j/comfy/mcp/ComfyMcpContent.java | 23 + .../easy4j/comfy/model/ComfyCliEnvelope.java | 26 + .../easy4j/comfy/ComfyClientConfigTest.java | 44 +- .../github/easy4j/comfy/ComfyClientTest.java | 96 +- .../comfy/cli/ComfyCliExecutorTest.java | 166 ++-- .../github/easy4j/comfy/cli/ComfyCliTest.java | 212 ++--- .../comfy/mcp/ComfyMcpClientE2ETest.java | 142 +-- .../easy4j/comfy/mcp/ComfyMcpConfigTest.java | 41 +- src/test/resources/comfy-envelope.sh | 2 + src/test/resources/comfy-slow.sh | 2 + src/test/resources/fake-mcp-server.py | 112 ++- 21 files changed, 1514 insertions(+), 1588 deletions(-) create mode 100644 src/main/java/io/github/easy4j/comfy/mcp/ComfyMcpContent.java create mode 100644 src/main/java/io/github/easy4j/comfy/model/ComfyCliEnvelope.java create mode 100755 src/test/resources/comfy-envelope.sh create mode 100755 src/test/resources/comfy-slow.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e3d63e..aac4ad6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,17 +1,9 @@ -# CI workflow for the feature/3.0.x line (JDK 21) -# -# Triggers: -# - push / pull_request on the feature/3.0.x branch -# - manual workflow_dispatch -# -# Runs `./mvnw -B clean verify` (Maven 4 via the checked-in wrapper — the -# runner's bundled Maven 3 cannot parse the POM 4.1.0 model) which includes -# the JaCoCo coverage gate (90% line coverage, haltOnFailure=false). +# CI for the Java 21 / Jackson 3 line. name: CI on: push: - branches: [feature/3.0.x] + branches: [feature/3.0.x, hardening/3.0.x] pull_request: branches: [feature/3.0.x] workflow_dispatch: @@ -35,7 +27,7 @@ jobs: java-version: '21' cache: maven - - name: Build and verify with JaCoCo coverage gate + - name: Build and verify run: ./mvnw -B --no-transfer-progress clean verify - name: Upload JaCoCo coverage report diff --git a/src/main/java/io/github/easy4j/comfy/ComfyClient.java b/src/main/java/io/github/easy4j/comfy/ComfyClient.java index 1ccb7b8..5980ed7 100644 --- a/src/main/java/io/github/easy4j/comfy/ComfyClient.java +++ b/src/main/java/io/github/easy4j/comfy/ComfyClient.java @@ -2,128 +2,61 @@ * Copyright (c) 2018-present, easy-4-java (https://github.com/easy-4-java). * * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package io.github.easy4j.comfy; import java.util.Objects; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import io.github.easy4j.comfy.cli.ComfyCli; import io.github.easy4j.comfy.cli.ComfyCliExecutor; import io.github.easy4j.comfy.cli.ComfyCliResult; +import io.github.easy4j.comfy.model.ComfyCliEnvelope; +import tools.jackson.databind.DeserializationFeature; import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.json.JsonMapper; /** - * High-level Java facade that wraps every local {@code comfy} CLI invocation - * behind ergonomic, strongly-typed methods. + * High-level Java facade for the local {@code comfy} CLI route. * - *

This class is the recommended entry point for the CLI route. It owns a - * single {@link ComfyClientConfig} and a single {@link ComfyCli}, forwarding - * the configured defaults to every call. For the MCP route (spawn - * {@code comfy-mcp} and speak JSON-RPC over stdio) use - * {@code io.github.easy4j.comfy.mcp.ComfyMcpClient}.

- * - * @author Loong Wan - * @since 1.0.0 - * @see ComfyClientConfig - * @see ComfyCli + *

The lower-level {@link ComfyCli} mirrors the CLI command tree; this class + * adds parsed JSON helpers while retaining access to the raw mapper.

*/ public class ComfyClient implements AutoCloseable { - private static final Logger log = LoggerFactory.getLogger(ComfyClient.class); - private static final JsonMapper MAPPER = new JsonMapper(); + private static final ObjectMapper MAPPER = + JsonMapper.builder().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).build(); private final ComfyClientConfig config; private final ComfyCli cli; - /** - * Creates a new client backed by the given configuration. A default - * {@link ComfyCli} and {@link ComfyCliExecutor} are constructed - * automatically. - * - * @param config runtime configuration; must not be {@code null}. - * @throws NullPointerException if {@code config} is {@code null}. - */ public ComfyClient(ComfyClientConfig config) { this.config = Objects.requireNonNull(config, "config"); this.config.validate(); this.cli = new ComfyCli(this.config, new ComfyCliExecutor(this.config)); } - /** - * Creates a new client that delegates to the supplied {@link ComfyCli}. - * - *

This constructor exists primarily for testing — it lets a - * caller substitute a {@link ComfyCli} backed by a mocked executor while - * still using the default behaviour of the surrounding facade.

- * - * @param config runtime configuration; must not be {@code null}. - * @param cli the CLI facade to delegate to; must not be {@code null}. - * @throws NullPointerException if either argument is {@code null}. - */ public ComfyClient(ComfyClientConfig config, ComfyCli cli) { this.config = Objects.requireNonNull(config, "config"); + this.config.validate(); this.cli = Objects.requireNonNull(cli, "cli"); } - /** - * Runs {@code comfy --version}. - * - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult version() { - return cli.version(); - } + public ComfyCliResult version() { return cli.version(); } + public ComfyCliResult help() { return cli.help(); } + public boolean isAvailable() { return cli.executor().probe(); } + public ComfyCliResult cloudLogin() { return cli.cloudLogin(); } + public ComfyCliResult setup() { return cli.setupYes(); } + public ComfyCliResult skillsInstall() { return cli.skillsInstall(); } /** - * Runs {@code comfy --help}. - * - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult help() { - return cli.help(); - } - - /** - * Probes CLI availability with {@code comfy --version} and the configured - * probe timeout. - * - * @return {@code true} when the local CLI is reachable. - */ - public boolean isAvailable() { - return cli.executor().probe(); - } - - /** - * Sends a generation request ({@code comfy generate }) with - * {@code --json} so the standard output can be parsed as JSON. - * - * @param model the generation model alias. - * @param options the generation options; must not be {@code null}. - * @return the parsed JSON root of the {@code --json} output; never - * {@code null}. - * @throws ComfyException when the invocation fails or prints non-JSON. + * Runs partner generation with command-level JSON output without mutating + * the caller's reusable options object. */ public JsonNode generateJson(String model, ComfyCli.GenerateOptions options) { - ComfyCli.GenerateOptions jsonOptions = options.json(true); - ComfyCliResult result = cli.generate(model, jsonOptions); - if (!result.isSuccess()) { - throw new ComfyException("comfy generate failed: exit=" + result.getExitCode() - + " stderr=" + result.getStderr()); - } + Objects.requireNonNull(options, "options"); + ComfyCliResult result = cli.generate(model, options.copy().json(true)); + requireSuccess(result, "comfy generate"); try { return MAPPER.readTree(result.getStdout()); } catch (Exception e) { @@ -131,57 +64,37 @@ public JsonNode generateJson(String model, ComfyCli.GenerateOptions options) { } } - /** - * Runs {@code comfy cloud login} (browser OAuth). - * - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult cloudLogin() { - return cli.cloudLogin(); + /** Executes any CLI command using the global uniform {@code --json} envelope. */ + public ComfyCliEnvelope executeJson(String... args) { + ComfyCliResult result = cli.executeJson(args); + requireSuccess(result, "comfy --json"); + if (result.isTruncated()) { + throw new ComfyException("comfy --json output exceeded maxOutputBytes=" + + config.getMaxOutputBytes()); + } + try { + return MAPPER.readValue(result.getStdout(), ComfyCliEnvelope.class); + } catch (Exception e) { + throw new ComfyException("comfy --json printed an invalid envelope", e); + } } - /** - * Runs {@code comfy setup -y} (non-interactive setup). - * - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult setup() { - return cli.setupYes(); - } + public ComfyCliEnvelope environment() { return executeJson("env"); } + public ComfyCliEnvelope whichJson() { return executeJson("which"); } + public ComfyCliEnvelope discover() { return executeJson("discover"); } - /** - * Runs {@code comfy skills install}. - * - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult skillsInstall() { - return cli.skillsInstall(); - } + public ComfyCli cli() { return cli; } + public ComfyClientConfig getConfig() { return config; } - /** - * Returns the underlying {@link ComfyCli} for advanced callers. - * - * @return the CLI facade backing this client; never {@code null}. - */ - public ComfyCli cli() { - return cli; - } - - /** - * Returns the runtime configuration used by this client. - * - * @return the configuration; never {@code null}. - */ - public ComfyClientConfig getConfig() { - return config; + private static void requireSuccess(ComfyCliResult result, String operation) { + if (!result.isSuccess()) { + throw new ComfyException(operation + " failed: exit=" + result.getExitCode() + + " stderr=" + result.getStderr()); + } } - /** - * Closes this client. The default implementation is a no-op because the - * underlying {@link ComfyCliExecutor} does not hold any long-lived - * resources. - */ @Override public void close() { + // CLI route owns no persistent subprocess or executor. } } diff --git a/src/main/java/io/github/easy4j/comfy/ComfyClientConfig.java b/src/main/java/io/github/easy4j/comfy/ComfyClientConfig.java index 6649d82..18502fe 100644 --- a/src/main/java/io/github/easy4j/comfy/ComfyClientConfig.java +++ b/src/main/java/io/github/easy4j/comfy/ComfyClientConfig.java @@ -2,16 +2,6 @@ * Copyright (c) 2018-present, easy-4-java (https://github.com/easy-4-java). * * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package io.github.easy4j.comfy; @@ -21,51 +11,43 @@ import lombok.Data; /** - * Configuration for the comfy CLI subprocess route. + * Runtime configuration for the local {@code comfy} CLI subprocess route. * - *

Plain POJO (Spring {@code @ConfigurationProperties}-bindable).

- * - * @author Loong Wan - * @since 1.0.0 - * @see ComfyClient + *

Credentials belong in {@link #environment}, never in argv, because + * command-line arguments can be visible to other local processes.

*/ @Data public class ComfyClientConfig { - /** Name or absolute path of the local {@code comfy} CLI executable. */ private String localExecutable = "comfy"; - - /** - * Extra environment variables for the child process (e.g. - * {@code COMFY_API_KEY}, {@code COMFY_WHERE}); merged over the parent - * environment. Credentials must travel here — never as command line - * arguments, which are visible in {@code ps} output. - */ private Map environment; - - /** Command execution timeout in seconds (generation runs can be long). */ private int localTimeoutSeconds = 600; - - /** Timeout in seconds used by {@link ComfyCliExecutor#probe()} when verifying CLI availability. */ private int localProbeTimeoutSeconds = 5; - - /** - * Default routing forwarded as {@code --where } to commands that - * accept it: {@code local} or {@code cloud}. The CLI also honours the - * {@code COMFY_WHERE} environment variable via {@link #environment}. - */ + private int maxOutputBytes = 16 * 1024 * 1024; private String defaultWhere; - /** - * Validates the configuration. - * - * @throws IllegalStateException when {@code defaultWhere} is neither - * {@code local} nor {@code cloud}. - */ public void validate() { Objects.requireNonNull(localExecutable, "localExecutable"); - if (defaultWhere != null && !"local".equals(defaultWhere) && !"cloud".equals(defaultWhere)) { - throw new IllegalStateException("defaultWhere must be 'local' or 'cloud': " + defaultWhere); + if (localExecutable.trim().isEmpty()) { + throw new IllegalStateException("localExecutable must not be blank"); + } + if (localTimeoutSeconds <= 0) { + throw new IllegalStateException("localTimeoutSeconds must be > 0"); + } + if (localProbeTimeoutSeconds <= 0) { + throw new IllegalStateException("localProbeTimeoutSeconds must be > 0"); + } + if (maxOutputBytes == 0 || maxOutputBytes < -1) { + throw new IllegalStateException("maxOutputBytes must be -1 (unbounded) or > 0"); + } + if (defaultWhere != null) { + requireWhere(defaultWhere); + } + } + + public static void requireWhere(String where) { + if (!"local".equals(where) && !"cloud".equals(where)) { + throw new IllegalArgumentException("where must be 'local' or 'cloud': " + where); } } } diff --git a/src/main/java/io/github/easy4j/comfy/ComfyException.java b/src/main/java/io/github/easy4j/comfy/ComfyException.java index 677961b..73b4a71 100644 --- a/src/main/java/io/github/easy4j/comfy/ComfyException.java +++ b/src/main/java/io/github/easy4j/comfy/ComfyException.java @@ -2,45 +2,15 @@ * Copyright (c) 2018-present, easy-4-java (https://github.com/easy-4-java). * * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package io.github.easy4j.comfy; /** - * Unchecked exception raised by the ACP and web-server routes when a turn or - * HTTP call fails: connection errors, JSON-RPC errors, malformed frames, - * read timeouts and non-zero business codes are all surfaced as this type. - * - * @author Loong Wan - * @since 1.0.0 + * Unchecked SDK exception for CLI execution/parsing and MCP transport/RPC + * failures. Business-level MCP tool errors remain represented by + * {@code ComfyMcpCallResult#isError()}. */ public class ComfyException extends RuntimeException { - - /** - * Creates an exception with a message. - * - * @param message human-readable description of the failure. - */ - public ComfyException(String message) { - super(message); - } - - /** - * Creates an exception with a message and a cause. - * - * @param message human-readable description of the failure. - * @param cause the underlying cause, may be {@code null}. - */ - public ComfyException(String message, Throwable cause) { - super(message, cause); - } + public ComfyException(String message) { super(message); } + public ComfyException(String message, Throwable cause) { super(message, cause); } } diff --git a/src/main/java/io/github/easy4j/comfy/cli/ComfyCli.java b/src/main/java/io/github/easy4j/comfy/cli/ComfyCli.java index a08b3ea..63247d4 100644 --- a/src/main/java/io/github/easy4j/comfy/cli/ComfyCli.java +++ b/src/main/java/io/github/easy4j/comfy/cli/ComfyCli.java @@ -2,207 +2,98 @@ * Copyright (c) 2018-present, easy-4-java (https://github.com/easy-4-java). * * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package io.github.easy4j.comfy.cli; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import io.github.easy4j.comfy.ComfyClientConfig; /** - * Maps every Java call onto a real {@code comfy} command line invocation. - * - *

Command surface mirrors the documented comfy CLI: setup, cloud auth, - * ComfyUI lifecycle, the {@code generate} family, workflow/job/discovery - * commands, skills management, and a raw escape hatch.

+ * Command mapper for the documented {@code comfy} CLI surface. * - * @author Loong Wan - * @since 1.0.0 - * @see ComfyCliExecutor - * @see ComfyCliResult + *

Named methods cover stable/high-frequency verbs while every command + * family also retains a varargs form and {@link #execute(String...)} remains + * the forward-compatible escape hatch. Use {@link #executeJson(String...)} + * with {@code comfy --json discover} when a newer CLI adds a verb before this + * SDK ships a named method.

*/ public class ComfyCli { private final ComfyCliExecutor executor; private final ComfyClientConfig config; - /** - * Creates a new command mapper. - * - * @param config runtime configuration providing the defaults. - * @param executor subprocess executor the invocations are forwarded to. - */ public ComfyCli(ComfyClientConfig config, ComfyCliExecutor executor) { this.config = Objects.requireNonNull(config, "config"); this.executor = Objects.requireNonNull(executor, "executor"); } - /** - * Returns the subprocess executor for advanced callers. - * - * @return the executor; never {@code null}. - */ public ComfyCliExecutor executor() { return executor; } - // ============================================================ - // basic info - // ============================================================ + // ---- global/basic ------------------------------------------------------ - /** - * Runs {@code comfy --version}. - * - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult version() { - return executor.execute("--version"); - } + public ComfyCliResult version() { return executor.execute("--version"); } + public ComfyCliResult help() { return executor.execute("--help"); } + public ComfyCliResult installCompletion() { return executor.execute("--install-completion"); } + public ComfyCliResult discoverJson() { return executeJson("discover"); } + public ComfyCliResult executeJson(String... args) { return executor.execute(prepend("--json", args)); } + public ComfyCliResult executeJsonStream(String... args) { return executor.execute(prepend("--json-stream", args)); } - /** - * Runs {@code comfy --help}. - * - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult help() { - return executor.execute("--help"); - } + // ---- setup/routing/cloud ---------------------------------------------- - /** - * Runs {@code comfy --install-completion} (shell completion). - * - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult installCompletion() { - return executor.execute("--install-completion"); - } + public ComfyCliResult setup() { return executor.execute("setup"); } + public ComfyCliResult setup(String... args) { return prefixed("setup", args); } + public ComfyCliResult setupYes() { return executor.execute("setup", "-y"); } - /** - * Runs {@code comfy --json discover} (agent discovery mode). - * - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult discoverJson() { - return executor.execute("--json", "discover"); - } - - // ============================================================ - // setup / cloud auth - // ============================================================ - - /** - * Runs {@code comfy setup} (interactive setup; the CLI prompts on its TTY). - * - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult setup() { - return executor.execute("setup"); - } - - /** - * Runs {@code comfy setup -y} (non-interactive, for CI/scripts). - * - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult setupYes() { - return executor.execute("setup", "-y"); - } - - /** - * Runs {@code comfy cloud login} (browser OAuth). - * - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult cloudLogin() { - return executor.execute("cloud", "login"); - } - - /** - * Runs {@code comfy cloud whoami}. - * - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult cloudWhoami() { - return executor.execute("cloud", "whoami"); + public ComfyCliResult cloud(String... args) { return prefixed("cloud", args); } + public ComfyCliResult cloudLogin() { return executor.execute("cloud", "login"); } + public ComfyCliResult cloudLoginNoBrowser() { return executor.execute("cloud", "login", "--no-browser"); } + public ComfyCliResult cloudWhoami() { return executor.execute("cloud", "whoami"); } + public ComfyCliResult cloudLogout() { return executor.execute("cloud", "logout"); } + public ComfyCliResult cloudStatus() { return executor.execute("cloud", "status"); } + public ComfyCliResult cloudSetBaseUrl(String url) { + return executor.execute("cloud", "set-base-url", Objects.requireNonNull(url, "url")); } - /** - * Runs {@code comfy set-default --where } to persist routing. - * - * @param where {@code local} or {@code cloud}. - * @return the raw CLI invocation result; never {@code null}. - */ public ComfyCliResult setDefaultWhere(String where) { - requireWhere(where); + ComfyClientConfig.requireWhere(where); return executor.execute("set-default", "--where", where); } - // ============================================================ - // local ComfyUI lifecycle - // ============================================================ - - /** - * Runs {@code comfy install } (workspace installation). - * - * @param args extra flags forwarded to the installer. - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult install(String... args) { - return prefixed("install", args); - } - - /** - * Runs {@code comfy launch } (start local ComfyUI). - * - * @param args extra flags forwarded to the launcher. - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult launch(String... args) { - return prefixed("launch", args); + public ComfyCliResult setDefaultWorkspace(String path) { + return executor.execute("set-default", Objects.requireNonNull(path, "path")); } - /** - * Runs {@code comfy stop} (stop local ComfyUI). - * - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult stop() { - return executor.execute("stop"); - } + // ---- local ComfyUI lifecycle/environment ------------------------------- - /** - * Runs {@code comfy update }. - * - * @param args extra flags forwarded to the updater. - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult update(String... args) { - return prefixed("update", args); + public ComfyCliResult install(String... args) { return prefixed("install", args); } + public ComfyCliResult launch(String... args) { return prefixed("launch", args); } + public ComfyCliResult launchBackground(String... args) { + List all = new ArrayList(); + all.add("launch"); + all.add("--background"); + addAll(all, args); + return executor.execute(all.toArray(new String[0])); } + public ComfyCliResult stop() { return executor.execute("stop"); } + public ComfyCliResult stop(String... args) { return prefixed("stop", args); } + public ComfyCliResult update(String... args) { return prefixed("update", args); } + public ComfyCliResult which() { return executor.execute("which"); } + public ComfyCliResult env() { return executor.execute("env"); } + public ComfyCliResult outdated() { return executor.execute("outdated"); } + public ComfyCliResult logs(String... args) { return prefixed("logs", args); } + public ComfyCliResult systemStats() { return executor.execute("system-stats"); } + public ComfyCliResult freeMemory() { return executor.execute("free-memory"); } + public ComfyCliResult project(String... args) { return prefixed("project", args); } - // ============================================================ - // generate - // ============================================================ + // ---- partner generation ------------------------------------------------ - /** - * Runs {@code comfy generate } with the given options. - * - * @param model the generation model alias (e.g. {@code flux-pro}). - * @param options the generation options; must not be {@code null}. - * @return the raw CLI invocation result; never {@code null}. - */ public ComfyCliResult generate(String model, GenerateOptions options) { Objects.requireNonNull(model, "model"); Objects.requireNonNull(options, "options"); @@ -212,213 +103,247 @@ public ComfyCliResult generate(String model, GenerateOptions options) { args.addAll(options.toArgs()); String where = options.where != null ? options.where : config.getDefaultWhere(); if (where != null) { + ComfyClientConfig.requireWhere(where); args.add("--where"); args.add(where); } return executor.execute(args.toArray(new String[0])); } - /** - * Runs {@code comfy generate list} with optional filters. - * - * @param category optional {@code --category} filter; may be {@code null}. - * @param partner optional {@code --partner} filter; may be {@code null}. - * @return the raw CLI invocation result; never {@code null}. - */ + public ComfyCliResult generate(String... args) { return prefixed("generate", args); } + public ComfyCliResult generateList(String category, String partner) { + return generateList(category, partner, null); + } + + public ComfyCliResult generateList(String category, String partner, String query) { List args = new ArrayList(); args.add("generate"); args.add("list"); - if (category != null) { - args.add("--category"); - args.add(category); - } - if (partner != null) { - args.add("--partner"); - args.add(partner); - } + if (category != null) { args.add("--category"); args.add(category); } + if (partner != null) { args.add("--partner"); args.add(partner); } + if (query != null) { args.add("--query"); args.add(query); } return executor.execute(args.toArray(new String[0])); } - /** - * Runs {@code comfy generate schema } (one model's parameters). - * - * @param model the model alias to introspect. - * @return the raw CLI invocation result; never {@code null}. - */ public ComfyCliResult generateSchema(String model) { - return executor.execute("generate", "schema", model); + return executor.execute("generate", "schema", Objects.requireNonNull(model, "model")); } - /** - * Runs {@code comfy generate upload } (prints a signed URL). - * - * @param file the local file to upload. - * @return the raw CLI invocation result; never {@code null}. - */ + public ComfyCliResult generateRefresh() { return executor.execute("generate", "refresh"); } + public ComfyCliResult generateUpload(String file) { - return executor.execute("generate", "upload", file); + return executor.execute("generate", "upload", Objects.requireNonNull(file, "file")); } - /** - * Runs {@code comfy generate resume } with optional download. - * - * @param model the model alias of the original job. - * @param jobId the async job id. - * @param download optional {@code --download} target; may be {@code null}. - * @return the raw CLI invocation result; never {@code null}. - */ public ComfyCliResult generateResume(String model, String jobId, String download) { List args = new ArrayList(); args.add("generate"); args.add("resume"); - args.add(model); - args.add(jobId); - if (download != null) { - args.add("--download"); - args.add(download); - } + args.add(Objects.requireNonNull(model, "model")); + args.add(Objects.requireNonNull(jobId, "jobId")); + if (download != null) { args.add("--download"); args.add(download); } return executor.execute(args.toArray(new String[0])); } - // ============================================================ - // workflows / jobs / discovery - // ============================================================ + // ---- workflows/jobs/templates ----------------------------------------- - /** - * Runs {@code comfy run } (execute a workflow). - * - * @param args workflow invocation flags. - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult run(String... args) { - return prefixed("run", args); - } + public ComfyCliResult run(String... args) { return prefixed("run", args); } - /** - * Runs {@code comfy jobs }. - * - * @param args job list/inspect flags. - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult jobs(String... args) { - return prefixed("jobs", args); + public ComfyCliResult runWorkflow(String workflowPath, boolean wait) { + List args = new ArrayList(); + args.add("run"); + args.add("--workflow"); + args.add(Objects.requireNonNull(workflowPath, "workflowPath")); + if (wait) { args.add("--wait"); } + return executor.execute(args.toArray(new String[0])); } - /** - * Runs {@code comfy validate }. - * - * @param args validation flags. - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult validate(String... args) { - return prefixed("validate", args); - } + public ComfyCliResult runTemplate(String... args) { return prefixed("run-template", args); } - /** - * Runs {@code comfy workflow }. - * - * @param args workflow management flags. - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult workflow(String... args) { - return prefixed("workflow", args); + public ComfyCliResult jobs(String... args) { return prefixed("jobs", args); } + public ComfyCliResult jobsList() { return executor.execute("jobs", "ls"); } + public ComfyCliResult jobStatus(String promptId) { + return executor.execute("jobs", "status", Objects.requireNonNull(promptId, "promptId")); + } + public ComfyCliResult jobWatch(String promptId) { + return executor.execute("jobs", "watch", Objects.requireNonNull(promptId, "promptId")); + } + public ComfyCliResult jobsWait(String... promptIds) { + List args = new ArrayList(); + args.add("jobs"); + args.add("wait"); + addAll(args, promptIds); + return executor.execute(args.toArray(new String[0])); + } + public ComfyCliResult jobCancel(String promptId) { + return executor.execute("jobs", "cancel", Objects.requireNonNull(promptId, "promptId")); } - /** - * Runs {@code comfy templates }. - * - * @param args template flags. - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult templates(String... args) { - return prefixed("templates", args); + public ComfyCliResult validate(String... args) { return prefixed("validate", args); } + public ComfyCliResult validateWorkflow(String workflowPath) { + return executor.execute("validate", "--workflow", Objects.requireNonNull(workflowPath, "workflowPath")); } - /** - * Runs {@code comfy nodes }. - * - * @param args node discovery flags. - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult nodes(String... args) { - return prefixed("nodes", args); + public ComfyCliResult templates(String... args) { return prefixed("templates", args); } + public ComfyCliResult templatesList(String type, String tag) { + List args = new ArrayList(); + args.add("templates"); args.add("ls"); + if (type != null) { args.add("--type"); args.add(type); } + if (tag != null) { args.add("--tag"); args.add(tag); } + return executor.execute(args.toArray(new String[0])); + } + public ComfyCliResult templateShow(String name) { + return executor.execute("templates", "show", Objects.requireNonNull(name, "name")); + } + public ComfyCliResult templateFetch(String name, String outPath) { + return executor.execute("templates", "fetch", Objects.requireNonNull(name, "name"), + "--out", Objects.requireNonNull(outPath, "outPath")); } - /** - * Runs {@code comfy models }. - * - * @param args model discovery flags. - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult models(String... args) { - return prefixed("models", args); + public ComfyCliResult workflow(String... args) { return prefixed("workflow", args); } + public ComfyCliResult workflowSlots(String path) { + return executor.execute("workflow", "slots", Objects.requireNonNull(path, "path")); + } + public ComfyCliResult workflowNotes(String path) { + return executor.execute("workflow", "notes", Objects.requireNonNull(path, "path")); + } + public ComfyCliResult workflowSetSlot(String path, String... overrides) { + List args = new ArrayList(); + args.add("workflow"); args.add("set-slot"); args.add(Objects.requireNonNull(path, "path")); + addAll(args, overrides); + return executor.execute(args.toArray(new String[0])); + } + public ComfyCliResult workflowVary(String path, String outDir, String... slotSpecs) { + List args = new ArrayList(); + args.add("workflow"); args.add("vary"); args.add(Objects.requireNonNull(path, "path")); + if (slotSpecs != null) { + for (String slot : slotSpecs) { + if (slot != null) { args.add("--slot"); args.add(slot); } + } + } + if (outDir != null) { args.add("--out-dir"); args.add(outDir); } + return executor.execute(args.toArray(new String[0])); + } + public ComfyCliResult workflowList() { return executor.execute("workflow", "list"); } + public ComfyCliResult workflowGet(String id, String outPath) { + List args = new ArrayList(); + args.add("workflow"); args.add("get"); args.add(Objects.requireNonNull(id, "id")); + if (outPath != null) { args.add("--out"); args.add(outPath); } + return executor.execute(args.toArray(new String[0])); + } + public ComfyCliResult workflowSave(String path, String name) { + List args = new ArrayList(); + args.add("workflow"); args.add("save"); args.add(Objects.requireNonNull(path, "path")); + if (name != null) { args.add("--name"); args.add(name); } + return executor.execute(args.toArray(new String[0])); + } + public ComfyCliResult workflowDelete(String id) { + return executor.execute("workflow", "delete", Objects.requireNonNull(id, "id")); + } + public ComfyCliResult workflowCompose(String blueprint, String outPath) { + return executor.execute("workflow", "compose", Objects.requireNonNull(blueprint, "blueprint"), + "-o", Objects.requireNonNull(outPath, "outPath")); + } + public ComfyCliResult workflowDecompose(String workflowPath) { + return executor.execute("workflow", "decompose", Objects.requireNonNull(workflowPath, "workflowPath")); } - // ============================================================ - // skills - // ============================================================ + // ---- discovery/assets/package management ------------------------------- - /** - * Runs {@code comfy skills install} (bundles agent skills). - * - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult skillsInstall() { - return executor.execute("skills", "install"); + public ComfyCliResult nodes(String... args) { return prefixed("nodes", args); } + public ComfyCliResult nodesSearch(String query) { + return executor.execute("nodes", "search", Objects.requireNonNull(query, "query")); + } + public ComfyCliResult nodeShow(String name) { + return executor.execute("nodes", "show", Objects.requireNonNull(name, "name")); + } + public ComfyCliResult nodesList(String... filters) { + List args = new ArrayList(); + args.add("nodes"); args.add("ls"); addAll(args, filters); + return executor.execute(args.toArray(new String[0])); } - /** - * Runs {@code comfy skills list}. - * - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult skillsList() { - return executor.execute("skills", "list"); + public ComfyCliResult models(String... args) { return prefixed("models", args); } + public ComfyCliResult modelFolders() { return executor.execute("models", "list-folders"); } + public ComfyCliResult modelsSearch(String text, String type) { + List args = new ArrayList(); + args.add("models"); args.add("search"); + if (text != null) { args.add("--text"); args.add(text); } + if (type != null) { args.add("--type"); args.add(type); } + return executor.execute(args.toArray(new String[0])); + } + public ComfyCliResult modelShow(String name) { + return executor.execute("models", "show", Objects.requireNonNull(name, "name")); } - /** - * Runs {@code comfy skills status}. - * - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult skillsStatus() { - return executor.execute("skills", "status"); + /** Custom-node management family ({@code comfy node ...}). */ + public ComfyCliResult node(String... args) { return prefixed("node", args); } + public ComfyCliResult nodeInstall(String name) { + return executor.execute("node", "install", Objects.requireNonNull(name, "name")); } - // ============================================================ - // passthrough - // ============================================================ + /** Local model-management family ({@code comfy model ...}). */ + public ComfyCliResult model(String... args) { return prefixed("model", args); } + public ComfyCliResult modelDownload(String url, String relativePath) { + List args = new ArrayList(); + args.add("model"); args.add("download"); args.add("--url"); + args.add(Objects.requireNonNull(url, "url")); + if (relativePath != null) { args.add("--relative-path"); args.add(relativePath); } + return executor.execute(args.toArray(new String[0])); + } - /** - * Runs an arbitrary {@code comfy} invocation; escape hatch for commands - * the SDK does not model yet. - * - * @param args full argument list after the executable. - * @return the raw CLI invocation result; never {@code null}. - */ - public ComfyCliResult execute(String... args) { - return executor.execute(args); + public ComfyCliResult upload(String... files) { return prefixed("upload", files); } + public ComfyCliResult download(String promptId, String... args) { + List all = new ArrayList(); + all.add("download"); + all.add(Objects.requireNonNull(promptId, "promptId")); + addAll(all, args); + return executor.execute(all.toArray(new String[0])); } + // ---- skills/telemetry -------------------------------------------------- + + public ComfyCliResult skills(String... args) { return prefixed("skills", args); } + public ComfyCliResult skillsInstall() { return executor.execute("skills", "install"); } + public ComfyCliResult skillsList() { return executor.execute("skills", "list"); } + public ComfyCliResult skillsStatus() { return executor.execute("skills", "status"); } + public ComfyCliResult trackingEnable() { return executor.execute("tracking", "enable"); } + public ComfyCliResult trackingDisable() { return executor.execute("tracking", "disable"); } + + // ---- escape hatch ------------------------------------------------------ + + public ComfyCliResult execute(String... args) { return executor.execute(args); } + private ComfyCliResult prefixed(String prefix, String... args) { - String[] all = new String[args.length + 1]; - all[0] = prefix; - System.arraycopy(args, 0, all, 1, args.length); - return executor.execute(all); + return executor.execute(prepend(prefix, args)); + } + + private static String[] prepend(String first, String... args) { + int length = args == null ? 0 : args.length; + String[] all = new String[length + 1]; + all[0] = first; + if (length > 0) { + System.arraycopy(args, 0, all, 1, length); + } + return all; } - private static void requireWhere(String where) { - if (!"local".equals(where) && !"cloud".equals(where)) { - throw new IllegalArgumentException("where must be 'local' or 'cloud': " + where); + private static void addAll(List target, String... values) { + if (values == null) { return; } + for (String value : values) { + if (value != null) { target.add(value); } } } /** * Fluent options for one {@code comfy generate } run. + * + *

The common cross-provider flags are typed. {@link #option(String, Object)} + * and {@link #flag(String)} preserve forward compatibility with provider + * parameters returned by {@code comfy generate schema }.

*/ public static class GenerateOptions { - private String prompt; private Integer width; private Integer height; @@ -429,108 +354,65 @@ public static class GenerateOptions { private Integer duration; private String aspectRatio; private String renderingSpeed; + private Integer timeoutSeconds; private boolean async; private boolean json; private String where; + private final Map extraOptions = new LinkedHashMap(); + private final List extraFlags = new ArrayList(); + + public GenerateOptions() {} + + public GenerateOptions(GenerateOptions source) { + Objects.requireNonNull(source, "source"); + this.prompt = source.prompt; + this.width = source.width; + this.height = source.height; + this.download = source.download; + this.image = source.image; + this.mask = source.mask; + this.resolution = source.resolution; + this.duration = source.duration; + this.aspectRatio = source.aspectRatio; + this.renderingSpeed = source.renderingSpeed; + this.timeoutSeconds = source.timeoutSeconds; + this.async = source.async; + this.json = source.json; + this.where = source.where; + this.extraOptions.putAll(source.extraOptions); + this.extraFlags.addAll(source.extraFlags); + } - /** - * Sets the {@code --prompt} flag. - * - * @param v the generation prompt. - * @return this builder for chaining. - */ + public GenerateOptions copy() { return new GenerateOptions(this); } public GenerateOptions prompt(String v) { this.prompt = v; return this; } - /** - * Sets the {@code --width} flag. - * - * @param v image width in pixels. - * @return this builder for chaining. - */ public GenerateOptions width(int v) { this.width = v; return this; } - /** - * Sets the {@code --height} flag. - * - * @param v image height in pixels. - * @return this builder for chaining. - */ public GenerateOptions height(int v) { this.height = v; return this; } - /** - * Sets the {@code --download} flag. - * - * @param v local target path for the generated asset. - * @return this builder for chaining. - */ public GenerateOptions download(String v) { this.download = v; return this; } - /** - * Sets the {@code --image} flag (input image; alias {@code --input_image}). - * - * @param v input image path. - * @return this builder for chaining. - */ public GenerateOptions image(String v) { this.image = v; return this; } - /** - * Sets the {@code --mask} flag. - * - * @param v mask image path. - * @return this builder for chaining. - */ public GenerateOptions mask(String v) { this.mask = v; return this; } - /** - * Sets the {@code --resolution} flag (e.g. {@code 1080p}). - * - * @param v video resolution preset. - * @return this builder for chaining. - */ public GenerateOptions resolution(String v) { this.resolution = v; return this; } - /** - * Sets the {@code --duration} flag (seconds, video models). - * - * @param v duration in seconds. - * @return this builder for chaining. - */ public GenerateOptions duration(int v) { this.duration = v; return this; } - /** - * Sets the {@code --aspect_ratio} flag (e.g. {@code 16:9}). - * - * @param v aspect ratio preset. - * @return this builder for chaining. - */ public GenerateOptions aspectRatio(String v) { this.aspectRatio = v; return this; } - /** - * Sets the {@code --rendering_speed} flag. - * - * @param v rendering speed preset. - * @return this builder for chaining. - */ public GenerateOptions renderingSpeed(String v) { this.renderingSpeed = v; return this; } - /** - * Sets the {@code --async} flag (returns a job id immediately). - * - * @param v {@code true} for asynchronous submission. - * @return this builder for chaining. - */ + public GenerateOptions timeoutSeconds(int v) { + if (v <= 0) { throw new IllegalArgumentException("timeoutSeconds must be > 0"); } + this.timeoutSeconds = v; return this; + } public GenerateOptions async(boolean v) { this.async = v; return this; } - /** - * Sets the {@code --json} flag (machine-readable output). - * - * @param v {@code true} to emit JSON. - * @return this builder for chaining. - */ public GenerateOptions json(boolean v) { this.json = v; return this; } - /** - * Overrides the routing for this run ({@code --where}). - * - * @param v {@code local} or {@code cloud}. - * @return this builder for chaining. - */ - public GenerateOptions where(String v) { this.where = v; return this; } - - /** - * Materialises the configured options into flags (without routing — - * the caller appends {@code --where} from config/override). - * - * @return the flag list. - */ + public GenerateOptions where(String v) { + if (v != null) { ComfyClientConfig.requireWhere(v); } + this.where = v; return this; + } + public GenerateOptions option(String name, Object value) { + extraOptions.put(optionName(name), String.valueOf(Objects.requireNonNull(value, "value"))); + return this; + } + public GenerateOptions flag(String name) { + extraFlags.add(optionName(name)); + return this; + } + List toArgs() { List args = new ArrayList(); if (prompt != null) { args.add("--prompt"); args.add(prompt); } @@ -543,9 +425,23 @@ List toArgs() { if (duration != null) { args.add("--duration"); args.add(String.valueOf(duration)); } if (aspectRatio != null) { args.add("--aspect_ratio"); args.add(aspectRatio); } if (renderingSpeed != null) { args.add("--rendering_speed"); args.add(renderingSpeed); } + if (timeoutSeconds != null) { args.add("--timeout"); args.add(String.valueOf(timeoutSeconds)); } if (async) { args.add("--async"); } if (json) { args.add("--json"); } + for (Map.Entry entry : extraOptions.entrySet()) { + args.add("--" + entry.getKey()); args.add(entry.getValue()); + } + for (String flag : extraFlags) { args.add("--" + flag); } return args; } + + private static String optionName(String name) { + Objects.requireNonNull(name, "name"); + String normalized = name.startsWith("--") ? name.substring(2) : name; + if (!normalized.matches("[A-Za-z0-9][A-Za-z0-9_-]*")) { + throw new IllegalArgumentException("invalid option name: " + name); + } + return normalized; + } } } diff --git a/src/main/java/io/github/easy4j/comfy/cli/ComfyCliExecutor.java b/src/main/java/io/github/easy4j/comfy/cli/ComfyCliExecutor.java index 2ef76b3..eda3d13 100644 --- a/src/main/java/io/github/easy4j/comfy/cli/ComfyCliExecutor.java +++ b/src/main/java/io/github/easy4j/comfy/cli/ComfyCliExecutor.java @@ -2,22 +2,13 @@ * Copyright (c) 2018-present, easy-4-java (https://github.com/easy-4-java). * * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package io.github.easy4j.comfy.cli; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; @@ -34,34 +25,11 @@ import io.github.easy4j.comfy.ComfyClientConfig; /** - * Thin wrapper around Apache Commons {@code exec} that launches the local - * {@code comfy} CLI as a child process. - * - *

Every call to {@link #execute(String...)} performs the following steps:

- *
    - *
  1. Build a {@link CommandLine} rooted at {@link ComfyClientConfig#getLocalExecutable()}.
  2. - *
  3. Append each non-{@code null} argument via - * {@link CommandLine#addArgument(String, boolean)} with - * {@code handleQuoting=false} — the child is spawned through - * {@code exec(argv)}, not a shell, so quoting would embed literal double - * quotes inside multi-word arguments (prompts, paths) and corrupt them - * on arrival.
  4. - *
  5. Capture stdout and stderr into in-memory buffers; the child always - * receives a (possibly empty) stdin pipe that closes right after the - * payload, so consumers reading to EOF cannot race the input pump.
  6. - *
  7. Run the process under an {@link ExecuteWatchdog} whose timeout is - * derived from {@link ComfyClientConfig#getLocalTimeoutSeconds()}.
  8. - *
  9. Return a {@link ComfyCliResult} preserving the real exit code and both - * captured streams.
  10. - *
+ * Synchronous, thread-safe subprocess executor for the local {@code comfy} CLI. * - *

The class is intentionally synchronous and stateless (apart from the - * injected configuration) so it can be safely shared between threads and - * pooled by higher-level components.

- * - * @author Loong Wan - * @since 1.0.0 - * @see ComfyCliResult + *

Arguments are passed as argv entries without a shell, stdout/stderr are + * drained concurrently, output capture is bounded, and the real exit code is + * preserved for non-zero exits.

*/ public class ComfyCliExecutor { @@ -69,143 +37,143 @@ public class ComfyCliExecutor { private final ComfyClientConfig config; - /** - * Creates a new executor bound to the given configuration. - * - * @param config the runtime configuration providing the executable path, - * timeout, and probe-timeout settings; must not be {@code null}. - */ public ComfyCliExecutor(ComfyClientConfig config) { this.config = Objects.requireNonNull(config, "config"); } - /** - * Runs the {@code kimi} executable with the given CLI arguments. - * - *

{@code null} entries in {@code args} are skipped silently to make - * varargs usage easier. Failure modes:

- *
    - *
  • Process timeout — {@link ComfyCliResult#isTimeout()} returns - * {@code true}; exit code is {@code -1}; stderr starts with the - * timeout notice.
  • - *
  • Non-zero process exit — the real exit code is preserved in - * {@link ComfyCliResult#getExitCode()}, and both captured streams are - * returned as-is.
  • - *
  • IOException (missing executable, permission denied, etc.) — - * the {@link IOException#getMessage()} is captured in - * {@link ComfyCliResult#getStderr()} and the exit code is {@code -1}.
  • - *
- * - * @param args CLI arguments to pass to the {@code kimi} binary. - * @return a {@link ComfyCliResult} describing the outcome; never {@code null}. - */ public ComfyCliResult execute(String... args) { - return runProcess(null, args); + return runProcess(null, secondsToMillis(config.getLocalTimeoutSeconds()), args); } - /** - * Runs the {@code kimi} executable with the given CLI arguments, feeding - * {@code stdin} to the child process. - * - *

Used by commands that read their payload from standard input. A - * {@code null} or empty {@code stdin} behaves exactly like - * {@link #execute(String...)} — the child receives an immediately - * closing pipe. Failure modes are identical to the varargs overload.

- * - * @param stdin optional text piped to the child process's standard input. - * @param args CLI arguments to pass to the {@code kimi} binary. - * @return a {@link ComfyCliResult} describing the outcome; never {@code null}. - */ public ComfyCliResult executeWithStdin(String stdin, String... args) { - return runProcess(stdin, args); + return runProcess(stdin, secondsToMillis(config.getLocalTimeoutSeconds()), args); + } + + ComfyCliResult executeWithTimeoutSeconds(int timeoutSeconds, String... args) { + return runProcess(null, secondsToMillis(timeoutSeconds), args); } - /** - * Lightweight reachability probe used by {@code KimiClient#isAvailable()}. - * - *

Runs {@code kimi --version} with the configured timeout and returns - * {@code true} only if the process exits with status {@code 0}. Any - * exception (missing executable, non-zero exit, timeout) is swallowed and - * reported as {@code false} so callers can use the result without a - * try/catch block.

- * - * @return {@code true} if the local CLI is reachable and reports a version, - * {@code false} otherwise. - */ public boolean probe() { try { - ComfyCliResult result = execute("--version"); - return result.isSuccess(); - } catch (Exception e) { + return executeWithTimeoutSeconds(config.getLocalProbeTimeoutSeconds(), "--version").isSuccess(); + } catch (RuntimeException e) { return false; } } - private ComfyCliResult runProcess(String stdin, String... args) { - CommandLine cmd = CommandLine.parse(config.getLocalExecutable()); - for (String arg : args) { - if (arg != null) { - // handleQuoting=false: the child is spawned via exec(argv), not - // a shell — commons-exec's default quoting would embed literal - // double quotes inside arguments containing spaces (prompts, - // paths), corrupting them on arrival. - cmd.addArgument(arg, false); + private ComfyCliResult runProcess(String stdin, long timeoutMs, String... args) { + CommandLine cmd = new CommandLine(config.getLocalExecutable()); + if (args != null) { + for (String arg : args) { + if (arg != null) { + cmd.addArgument(arg, false); + } } } DefaultExecutor executor = new DefaultExecutor(); - // Credentials (COMFY_API_KEY) and routing hints must travel via the - // environment — argv is world-readable in `ps` output. commons-exec's - // environment parameter REPLACES the parent environment, so merge the - // overrides over System.getenv() to keep PATH (bare `comfy` lookup - // depends on it). Map childEnv = null; if (config.getEnvironment() != null && !config.getEnvironment().isEmpty()) { childEnv = new LinkedHashMap(System.getenv()); childEnv.putAll(config.getEnvironment()); } - ByteArrayOutputStream stdout = new ByteArrayOutputStream(); - ByteArrayOutputStream stderr = new ByteArrayOutputStream(); - // Always hand the child a (possibly empty) stdin pipe that closes - // right after the payload: consumers reading to EOF finish instantly, - // and a closed pipe cannot race the input pump. + + BoundedOutput stdout = new BoundedOutput(config.getMaxOutputBytes()); + BoundedOutput stderr = new BoundedOutput(config.getMaxOutputBytes()); byte[] stdinBytes = stdin == null ? new byte[0] : stdin.getBytes(StandardCharsets.UTF_8); - executor.setStreamHandler(new PumpStreamHandler(stdout, stderr, - new ByteArrayInputStream(stdinBytes))); + executor.setStreamHandler(new PumpStreamHandler(stdout, stderr, new ByteArrayInputStream(stdinBytes))); - long timeoutMs = config.getLocalTimeoutSeconds() * 1000L; ExecuteWatchdog watchdog = new ExecuteWatchdog(timeoutMs); executor.setWatchdog(watchdog); - long startNanos = System.nanoTime(); + try { int exitCode = childEnv == null ? executor.execute(cmd) : executor.execute(cmd, childEnv); - String out = stdout.toString(StandardCharsets.UTF_8).trim(); - String err = stderr.toString(StandardCharsets.UTF_8).trim(); - log.debug("comfy CLI executed: exitCode={}, stdout.len={}", exitCode, out.length()); + String out = stdout.asUtf8().trim(); + String err = stderr.asUtf8().trim(); + log.debug("comfy CLI executed: exitCode={}, stdout.len={}, stdout.truncated={}, stderr.truncated={}", + exitCode, out.length(), stdout.isTruncated(), stderr.isTruncated()); if (watchdog.killedProcess()) { - return new ComfyCliResult(-1, out, "comfy CLI timed out after " + timeoutMs + " ms\n" + err); + return result(-1, out, timeoutMessage(timeoutMs, err), stdout, stderr); } - return new ComfyCliResult(exitCode, out, err); + return result(exitCode, out, err, stdout, stderr); } catch (ExecuteException e) { - // commons-exec throws ExecuteException for EVERY non-zero exit - // (and for watchdog kills). The stream pumps are joined before it - // is thrown, so both buffers are complete — surface them together - // with the real exit code instead of discarding the output. The - // deadline check makes the timeout verdict race-free even when - // {@code watchdog.killedProcess()} has not observed the kill yet. - String out = stdout.toString(StandardCharsets.UTF_8).trim(); - String err = stderr.toString(StandardCharsets.UTF_8).trim(); + String out = stdout.asUtf8().trim(); + String err = stderr.asUtf8().trim(); boolean timedOut = watchdog.killedProcess() || System.nanoTime() - startNanos >= timeoutMs * 1_000_000L; if (timedOut) { - return new ComfyCliResult(-1, out, "comfy CLI timed out after " + timeoutMs + " ms\n" + err); + return result(-1, out, timeoutMessage(timeoutMs, err), stdout, stderr); } - log.debug("comfy CLI failed: exitCode={}, stdout.len={}, stderr.len={}", - e.getExitValue(), out.length(), err.length()); - return new ComfyCliResult(e.getExitValue(), out, err); + return result(e.getExitValue(), out, err, stdout, stderr); } catch (IOException e) { - return new ComfyCliResult(-1, "", e.getMessage()); + return result(-1, "", safeMessage(e), stdout, stderr); + } + } + + private static ComfyCliResult result(int exitCode, String out, String err, + BoundedOutput stdout, BoundedOutput stderr) { + return new ComfyCliResult(exitCode, out, err, stdout.isTruncated(), stderr.isTruncated()); + } + + private static String timeoutMessage(long timeoutMs, String stderr) { + String prefix = "comfy CLI timed out after " + timeoutMs + " ms"; + return stderr == null || stderr.isEmpty() ? prefix : prefix + "\n" + stderr; + } + + private static String safeMessage(IOException error) { + return error.getMessage() == null ? error.getClass().getSimpleName() : error.getMessage(); + } + + private static long secondsToMillis(int seconds) { + return Math.max(1L, (long) seconds) * 1000L; + } + + private static final class BoundedOutput extends OutputStream { + private final ByteArrayOutputStream delegate = new ByteArrayOutputStream(); + private final int maxBytes; + private boolean truncated; + + private BoundedOutput(int maxBytes) { + this.maxBytes = maxBytes; + } + + @Override + public synchronized void write(int b) { + if (maxBytes < 0 || delegate.size() < maxBytes) { + delegate.write(b); + } else { + truncated = true; + } + } + + @Override + public synchronized void write(byte[] bytes, int off, int len) { + if (len <= 0) { + return; + } + if (maxBytes < 0) { + delegate.write(bytes, off, len); + return; + } + int remaining = maxBytes - delegate.size(); + if (remaining > 0) { + int accepted = Math.min(remaining, len); + delegate.write(bytes, off, accepted); + if (accepted < len) { + truncated = true; + } + } else { + truncated = true; + } + } + + private synchronized String asUtf8() { + return new String(delegate.toByteArray(), StandardCharsets.UTF_8); + } + + private synchronized boolean isTruncated() { + return truncated; } } } diff --git a/src/main/java/io/github/easy4j/comfy/cli/ComfyCliResult.java b/src/main/java/io/github/easy4j/comfy/cli/ComfyCliResult.java index babcf3d..915dc2b 100644 --- a/src/main/java/io/github/easy4j/comfy/cli/ComfyCliResult.java +++ b/src/main/java/io/github/easy4j/comfy/cli/ComfyCliResult.java @@ -2,16 +2,6 @@ * Copyright (c) 2018-present, easy-4-java (https://github.com/easy-4-java). * * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package io.github.easy4j.comfy.cli; @@ -19,45 +9,40 @@ import lombok.Data; -/** - * Outcome of one {@code comfy} CLI invocation: exit code plus both captured - * streams. Non-zero exits preserve the real exit code and both buffers — - * nothing is collapsed into a sentinel value. - * - * @author Loong Wan - * @since 1.0.0 - * @see ComfyCliExecutor - */ +/** Outcome of one {@code comfy} CLI invocation. */ @Data public class ComfyCliResult { private static final String TIMEOUT_PREFIX = "comfy CLI timed out after "; - /** Process exit code; {@code -1} when the executable was missing or the run timed out. */ private final int exitCode; - - /** Trimmed standard output of the child process. */ private final String stdout; - - /** Trimmed standard error of the child process. */ private final String stderr; + private final boolean stdoutTruncated; + private final boolean stderrTruncated; + + public ComfyCliResult(int exitCode, String stdout, String stderr) { + this(exitCode, stdout, stderr, false, false); + } + + public ComfyCliResult(int exitCode, String stdout, String stderr, + boolean stdoutTruncated, boolean stderrTruncated) { + this.exitCode = exitCode; + this.stdout = stdout; + this.stderr = stderr; + this.stdoutTruncated = stdoutTruncated; + this.stderrTruncated = stderrTruncated; + } - /** - * Returns whether the invocation exited with status zero. - * - * @return {@code true} when {@code exitCode == 0}. - */ public boolean isSuccess() { return exitCode == 0; } - /** - * Returns whether the run was terminated by the watchdog timeout. - * - * @return {@code true} when the exit code is {@code -1} and stderr carries - * the timeout notice. - */ public boolean isTimeout() { return exitCode == -1 && Objects.nonNull(stderr) && stderr.startsWith(TIMEOUT_PREFIX); } + + public boolean isTruncated() { + return stdoutTruncated || stderrTruncated; + } } diff --git a/src/main/java/io/github/easy4j/comfy/mcp/ComfyMcpCallResult.java b/src/main/java/io/github/easy4j/comfy/mcp/ComfyMcpCallResult.java index b1a7a75..8c258d6 100644 --- a/src/main/java/io/github/easy4j/comfy/mcp/ComfyMcpCallResult.java +++ b/src/main/java/io/github/easy4j/comfy/mcp/ComfyMcpCallResult.java @@ -2,42 +2,37 @@ * Copyright (c) 2018-present, easy-4-java (https://github.com/easy-4-java). * * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package io.github.easy4j.comfy.mcp; -import lombok.Data; +import java.util.Collections; +import java.util.List; +import lombok.Data; import tools.jackson.databind.JsonNode; -/** - * Outcome of one {@code tools/call}. - * - * @author Loong Wan - * @since 1.0.0 - * @see ComfyMcpClient#callTool(String, java.util.Map) - */ +/** Outcome of one MCP {@code tools/call}. */ @Data public class ComfyMcpCallResult { - - /** - * Concatenated text of every {@code text} content item the tool returned, - * subject to {@code maxContentChars} truncation. - */ private final String text; - - /** Whether the tool reported a business-level failure ({@code isError}). */ private final boolean isError; - - /** The raw JSON-RPC result node, for callers needing non-text content. */ private final JsonNode raw; + private final List contents; + private final boolean textTruncated; + + /** Backward-compatible constructor retained for existing SDK callers. */ + public ComfyMcpCallResult(String text, boolean isError, JsonNode raw) { + this(text, isError, raw, Collections.emptyList(), false); + } + + public ComfyMcpCallResult(String text, boolean isError, JsonNode raw, + List contents, boolean textTruncated) { + this.text = text; + this.isError = isError; + this.raw = raw; + this.contents = contents == null + ? Collections.emptyList() + : Collections.unmodifiableList(contents); + this.textTruncated = textTruncated; + } } diff --git a/src/main/java/io/github/easy4j/comfy/mcp/ComfyMcpClient.java b/src/main/java/io/github/easy4j/comfy/mcp/ComfyMcpClient.java index 4a611cc..b2117fa 100644 --- a/src/main/java/io/github/easy4j/comfy/mcp/ComfyMcpClient.java +++ b/src/main/java/io/github/easy4j/comfy/mcp/ComfyMcpClient.java @@ -2,35 +2,28 @@ * Copyright (c) 2018-present, easy-4-java (https://github.com/easy-4-java). * * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package io.github.easy4j.comfy.mcp; -import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.io.PrintWriter; +import java.io.Reader; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; @@ -39,31 +32,18 @@ import org.slf4j.LoggerFactory; import io.github.easy4j.comfy.ComfyException; +import tools.jackson.databind.DeserializationFeature; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.json.JsonMapper; /** - * Client for the comfy-mcp route: spawns the MCP server as a child process - * and drives it over JSON-RPC on stdio (newline-delimited JSON, the MCP - * stdio transport). - * - *

Lifecycle: {@link #connect()} performs the MCP {@code initialize} - * handshake and completes the {@code notifications/initialized} sequence; - * {@link #listTools()} discovers the tool catalog ({@code server_info}, - * {@code run_workflow}, {@code search_templates}, {@code launch_comfyui}, - * …); {@link #callTool(String, Map)} executes one tool and returns the - * concatenated text content. {@link #close()} terminates the server.

+ * Local Comfy MCP client using newline-delimited JSON-RPC over stdio. * - *

The client is thread-safe: concurrent {@code tools/call} requests are - * correlated by JSON-RPC id. Each client owns exactly one server child - * process; {@link #close()} destroys it.

- * - * @author Loong Wan - * @since 1.0.0 - * @see ComfyMcpConfig - * @see ComfyMcpTool - * @see ComfyMcpCallResult + *

One client owns exactly one child process. Requests may be concurrent and + * are correlated by JSON-RPC id. Both stdout and stderr are continuously + * drained; pending requests are removed on response, timeout, write failure, + * process exit and close.

*/ public class ComfyMcpClient implements AutoCloseable { @@ -71,8 +51,7 @@ public class ComfyMcpClient implements AutoCloseable { private final ComfyMcpConfig config; private final ObjectMapper mapper = - JsonMapper.builder().disable(tools.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) - .build(); + JsonMapper.builder().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).build(); private final Map> pendingRpcs = new ConcurrentHashMap>(); private final AtomicLong rpcIds = new AtomicLong(); @@ -83,78 +62,88 @@ public class ComfyMcpClient implements AutoCloseable { thread.setDaemon(true); return thread; }); + private final Object writeLock = new Object(); private volatile Process process; private volatile PrintWriter stdin; + private volatile Thread stdoutThread; + private volatile Thread stderrThread; private volatile String serverName; private volatile String serverVersion; - /** - * Creates a new client bound to the given configuration. - * - * @param config runtime configuration; must not be {@code null}. - */ public ComfyMcpClient(ComfyMcpConfig config) { this.config = Objects.requireNonNull(config, "config"); this.config.validate(); } /** - * Spawns the MCP server child process and performs the MCP - * {@code initialize} handshake plus {@code notifications/initialized}. + * Starts {@code comfy-mcp} and performs MCP initialize/initialized. * - * @return the server-reported version string, may be {@code null}. - * @throws ComfyException when the process fails to start or the handshake - * fails or times out. + * @throws IllegalStateException if already connected or closed. */ - public String connect() { + public synchronized String connect() { + if (closed.get()) { + throw new IllegalStateException("comfy mcp client is closed"); + } + if (initialized.get() || process != null) { + throw new IllegalStateException("comfy mcp client is already connected"); + } + List command = new ArrayList(); command.add(config.getLocalExecutable()); if (config.getMcpArgs() != null) { - for (String arg : config.getMcpArgs()) { - command.add(arg); - } + Collections.addAll(command, config.getMcpArgs()); } + ProcessBuilder builder = new ProcessBuilder(command); builder.redirectErrorStream(false); if (config.getEnvironment() != null && !config.getEnvironment().isEmpty()) { builder.environment().putAll(config.getEnvironment()); } + try { - process = builder.start(); + Process child = builder.start(); + process = child; + stdin = new PrintWriter(new OutputStreamWriter(child.getOutputStream(), StandardCharsets.UTF_8), true); + stdoutThread = daemon("comfy-mcp-reader", () -> readLoop(child)); + stderrThread = daemon("comfy-mcp-stderr", () -> drainStderr(child)); + stdoutThread.start(); + stderrThread.start(); + + Map clientInfo = new LinkedHashMap(); + clientInfo.put("name", config.getClientName()); + clientInfo.put("version", config.getClientVersion()); + Map params = new LinkedHashMap(); + params.put("protocolVersion", config.getProtocolVersion()); + params.put("capabilities", new LinkedHashMap()); + params.put("clientInfo", clientInfo); + + JsonNode result = await( + request("initialize", params, config.getConnectTimeoutMillis(), "initialize"), + "initialize"); + notify("notifications/initialized", new LinkedHashMap()); + if (result.hasNonNull("serverInfo")) { + serverName = result.path("serverInfo").path("name").asText(null); + serverVersion = result.path("serverInfo").path("version").asText(null); + } + initialized.set(true); + return serverVersion; } catch (IOException e) { + cleanupTransport(); throw new ComfyException("Failed to spawn comfy-mcp: " + config.getLocalExecutable(), e); + } catch (RuntimeException e) { + failAllPending(new ComfyException("comfy mcp connect failed", e)); + cleanupTransport(); + throw e; } - stdin = new PrintWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8), true); - Thread reader = new Thread(this::readLoop, "comfy-mcp-reader"); - reader.setDaemon(true); - reader.start(); - - Map clientInfo = new LinkedHashMap(); - clientInfo.put("name", config.getClientName()); - clientInfo.put("version", config.getClientVersion()); - Map params = new LinkedHashMap(); - params.put("protocolVersion", config.getProtocolVersion()); - params.put("capabilities", new LinkedHashMap()); - params.put("clientInfo", clientInfo); - JsonNode result = await(request("initialize", params), config.getConnectTimeoutMillis(), "initialize"); - notify("notifications/initialized", new LinkedHashMap()); - if (result.hasNonNull("serverInfo")) { - serverName = result.path("serverInfo").path("name").asText(null); - serverVersion = result.path("serverInfo").path("version").asText(null); - } - initialized.set(true); - return serverVersion; } - /** - * Lists the tools the server advertises. - * - * @return the tool catalog; never {@code null}. - */ public List listTools() { - JsonNode result = await(request("tools/list", new LinkedHashMap()), - config.getConnectTimeoutMillis(), "tools/list"); + ensureConnected(); + JsonNode result = await( + request("tools/list", new LinkedHashMap(), + config.getConnectTimeoutMillis(), "tools/list"), + "tools/list"); List tools = new ArrayList(); for (JsonNode tool : result.path("tools")) { tools.add(new ComfyMcpTool( @@ -165,166 +154,339 @@ public List listTools() { return tools; } - /** - * Calls one tool asynchronously. - * - * @param name the tool name. - * @param arguments tool arguments (JSON-Schema-shaped); may be {@code null}. - * @return a future completed with the call result, or completed - * exceptionally with a {@link ComfyException}. - */ - public CompletableFuture callToolAsync(String name, Map arguments) { + public CompletableFuture callToolAsync( + String name, Map arguments) { Objects.requireNonNull(name, "name"); - if (closed.get()) { - throw new IllegalStateException("comfy mcp client is closed"); - } - if (!initialized.get()) { - throw new IllegalStateException("comfy mcp client is not connected"); - } + ensureConnected(); Map params = new LinkedHashMap(); params.put("name", name); - params.put("arguments", arguments == null ? new LinkedHashMap() : arguments); - CompletableFuture response = request("tools/call", params); - CompletableFuture future = response.thenApply(this::toCallResult); - scheduleTimeout(future, config.getReadTimeoutMillis(), "tools/call " + name); - return future; + params.put("arguments", arguments == null + ? new LinkedHashMap() : new LinkedHashMap(arguments)); + return request("tools/call", params, config.getReadTimeoutMillis(), "tools/call " + name) + .thenApply(this::toCallResult); } - /** - * Calls one tool, blocking until it completes. - * - * @param name the tool name. - * @param arguments tool arguments; may be {@code null}. - * @return the call result; never {@code null}. - * @throws ComfyException when the call fails or times out. - */ public ComfyMcpCallResult callTool(String name, Map arguments) { - try { - return callToolAsync(name, arguments).get(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new ComfyException("comfy mcp tools/call interrupted", e); - } catch (ExecutionException e) { - Throwable cause = e.getCause() == null ? e : e.getCause(); - if (cause instanceof ComfyException) { - throw (ComfyException) cause; - } - if (cause instanceof RuntimeException) { - throw (RuntimeException) cause; - } - throw new ComfyException("comfy mcp tools/call failed", cause); - } + return awaitCall(callToolAsync(name, arguments), "tools/call " + name); } - /** - * Convenience wrapper for the {@code server_info} tool ("call first" per - * the Comfy docs — verifies the local ComfyUI is up). - * - * @return the raw JSON-RPC result node; never {@code null}. - */ - public JsonNode serverInfo() { - return rawCall("server_info"); + // ---- current comfy-mcp typed conveniences ------------------------------ + + public JsonNode serverInfo() { return callTool("server_info", null).getRaw(); } + public ComfyMcpCallResult authStatus() { return callTool("auth_status", null); } + public ComfyMcpCallResult billingStatus() { return callTool("billing_status", null); } + public ComfyMcpCallResult authLogin() { return callTool("auth_login", null); } + + public ComfyMcpCallResult runWorkflow(String workflowPath, boolean wait, + double timeoutSeconds, boolean confirmSpend) { + return callTool("run_workflow", params( + "workflow_path", Objects.requireNonNull(workflowPath, "workflowPath"), + "wait", Boolean.valueOf(wait), + "timeout_seconds", Double.valueOf(timeoutSeconds), + "confirm_spend", Boolean.valueOf(confirmSpend))); } - /** - * Returns the server name reported by {@code initialize}, or {@code null} - * before {@link #connect()}. - * - * @return the server name, may be {@code null}. - */ - public String getServerName() { - return serverName; + public ComfyMcpCallResult generateImage(String prompt, String checkpoint, + boolean wait, double timeoutSeconds) { + Map args = params( + "prompt", Objects.requireNonNull(prompt, "prompt"), + "wait", Boolean.valueOf(wait), + "timeout_seconds", Double.valueOf(timeoutSeconds)); + putIfNotNull(args, "checkpoint", checkpoint); + return callTool("generate_image", args); } - /** - * Returns the server version reported by {@code initialize}, or - * {@code null} before {@link #connect()}. - * - * @return the server version, may be {@code null}. - */ - public String getServerVersion() { - return serverVersion; + public ComfyMcpCallResult listPartnerModels(String style, String partner, + String query, int limit, int offset) { + return callTool("list_partner_models", params( + "style", nullToEmpty(style), + "partner", nullToEmpty(partner), + "query", nullToEmpty(query), + "limit", Integer.valueOf(limit), + "offset", Integer.valueOf(offset))); } - /** - * Returns whether the client has been closed. - * - * @return {@code true} after {@link #close()}. - */ - public boolean isClosed() { - return closed.get(); + public ComfyMcpCallResult partnerModelSchema(String model) { + return callTool("partner_model_schema", + params("model", Objects.requireNonNull(model, "model"))); } - /** - * Terminates the MCP server child process and releases the timer. - * Idempotent. - */ - @Override - public void close() { - if (!closed.compareAndSet(false, true)) { - return; - } - timer.shutdownNow(); - Process current = process; - if (current != null) { - current.destroy(); - } - failAllPending(new ComfyException("comfy mcp client closed")); + public ComfyMcpCallResult partnerGenerate(String model, Map modelParams, + boolean confirmSpend, String outPath, double timeoutSeconds) { + Map args = params( + "model", Objects.requireNonNull(model, "model"), + "params", modelParams == null ? new LinkedHashMap() : modelParams, + "confirm_spend", Boolean.valueOf(confirmSpend), + "timeout_seconds", Double.valueOf(timeoutSeconds)); + putIfNotNull(args, "out_path", outPath); + return callTool("partner_generate", args); } - // ============================================================ - // transport internals - // ============================================================ + public ComfyMcpCallResult emitPartnerWorkflow(String model, String outPath, + Map modelParams) { + return callTool("emit_partner_workflow", params( + "model", Objects.requireNonNull(model, "model"), + "out_path", Objects.requireNonNull(outPath, "outPath"), + "params", modelParams == null ? new LinkedHashMap() : modelParams)); + } - private JsonNode rawCall(String tool) { - return await(request("tools/call", paramsFor(tool, null)), - config.getReadTimeoutMillis(), "tools/call " + tool); + public ComfyMcpCallResult runTemplate(String name, Map templateParams, + boolean confirmSpend, boolean wait, double timeoutSeconds) { + return callTool("run_template", params( + "name", Objects.requireNonNull(name, "name"), + "params", templateParams == null ? new LinkedHashMap() : templateParams, + "confirm_spend", Boolean.valueOf(confirmSpend), + "wait", Boolean.valueOf(wait), + "timeout_seconds", Double.valueOf(timeoutSeconds))); } - private Map paramsFor(String name, Map arguments) { - Map params = new LinkedHashMap(); - params.put("name", name); - params.put("arguments", arguments == null ? new LinkedHashMap() : arguments); - return params; + public ComfyMcpCallResult job(String action, String promptId, Double timeoutSeconds) { + Map args = params("action", action == null ? "status" : action); + putIfNotNull(args, "prompt_id", promptId); + putIfNotNull(args, "timeout_seconds", timeoutSeconds); + return callTool("job", args); } - private ComfyMcpCallResult toCallResult(JsonNode result) { - StringBuilder text = new StringBuilder(); - int cap = config.getMaxContentChars() <= 0 ? Integer.MAX_VALUE : config.getMaxContentChars(); - boolean truncated = false; - for (JsonNode content : result.path("content")) { - if (!"text".equals(content.path("type").asText(""))) { - continue; - } - String piece = content.path("text").asText(""); - if (text.length() >= cap) { - truncated = true; - break; - } - if (text.length() + piece.length() > cap) { - text.append(piece, 0, cap - text.length()); - truncated = true; - break; - } - text.append(piece); - } - if (truncated) { - log.warn("comfy mcp tool content truncated at maxContentChars={}", config.getMaxContentChars()); + public ComfyMcpCallResult jobStatus(String promptId) { return job("status", promptId, null); } + public ComfyMcpCallResult waitForJob(String promptId, double timeoutSeconds) { + return job("wait", promptId, Double.valueOf(timeoutSeconds)); + } + public ComfyMcpCallResult watchJob(String promptId, double timeoutSeconds) { + return job("watch", promptId, Double.valueOf(timeoutSeconds)); + } + public ComfyMcpCallResult cancelJob(String promptId) { return job("cancel", promptId, null); } + public ComfyMcpCallResult getQueue() { return job("queue", null, null); } + + public ComfyMcpCallResult systemStats() { return callTool("system_stats", null); } + public ComfyMcpCallResult freeMemory() { return callTool("free_memory", null); } + + public ComfyMcpCallResult fetchOutputs(String promptId, String outDir, + boolean urlOnly, boolean inlineImages) { + return callTool("fetch_outputs", params( + "prompt_id", Objects.requireNonNull(promptId, "promptId"), + "out_dir", Objects.requireNonNull(outDir, "outDir"), + "url_only", Boolean.valueOf(urlOnly), + "inline_images", Boolean.valueOf(inlineImages))); + } + + public ComfyMcpCallResult launchComfyUi(List extraArgs, boolean confirmNetworkExposure) { + return callTool("launch_comfyui", params( + "extra_args", extraArgs == null ? Collections.emptyList() : extraArgs, + "confirm_network_exposure", Boolean.valueOf(confirmNetworkExposure))); + } + + public ComfyMcpCallResult stopComfyUi() { return callTool("stop_comfyui", null); } + + public ComfyMcpCallResult restartComfyUi(List extraArgs, + boolean confirmNetworkExposure, boolean confirmKillUntracked) { + return callTool("restart_comfyui", params( + "extra_args", extraArgs == null ? Collections.emptyList() : extraArgs, + "confirm_network_exposure", Boolean.valueOf(confirmNetworkExposure), + "confirm_kill_untracked", Boolean.valueOf(confirmKillUntracked))); + } + + public ComfyMcpCallResult updateComfyUi(String target, boolean confirmUpdateAll) { + return callTool("update_comfyui", params( + "target", target == null ? "comfy" : target, + "confirm_update_all", Boolean.valueOf(confirmUpdateAll))); + } + + public ComfyMcpCallResult switchComfyUiVersion(String version, boolean confirmSwitch) { + return callTool("switch_comfyui_version", params( + "version", Objects.requireNonNull(version, "version"), + "confirm_switch", Boolean.valueOf(confirmSwitch))); + } + + public ComfyMcpCallResult installNode(List names, boolean confirmInstall) { + return callTool("install_node", params( + "names", Objects.requireNonNull(names, "names"), + "confirm_install", Boolean.valueOf(confirmInstall))); + } + + public ComfyMcpCallResult getLogs(int tail, Integer port) { + Map args = params("tail", Integer.valueOf(tail)); + putIfNotNull(args, "port", port); + return callTool("get_logs", args); + } + + public ComfyMcpCallResult discover() { return callTool("discover", null); } + public ComfyMcpCallResult which() { return callTool("which", null); } + + public ComfyMcpCallResult project(String action) { + return callTool("project", params("action", action == null ? "status" : action)); + } + + public ComfyMcpCallResult searchTemplates(String query, int limit, int offset, + String tag, String type, String model, String provider, boolean excludeApi) { + return callTool("search_templates", params( + "query", nullToEmpty(query), + "limit", Integer.valueOf(limit), + "offset", Integer.valueOf(offset), + "tag", nullToEmpty(tag), + "type", nullToEmpty(type), + "model", nullToEmpty(model), + "provider", nullToEmpty(provider), + "exclude_api", Boolean.valueOf(excludeApi))); + } + + public ComfyMcpCallResult getTemplate(String name) { + return getTemplate(name, true); + } + + public ComfyMcpCallResult getTemplate(String name, boolean checkLocal) { + return callTool("get_template", params( + "name", Objects.requireNonNull(name, "name"), + "check_local", Boolean.valueOf(checkLocal))); + } + + public ComfyMcpCallResult fetchTemplate(String name, String outPath, boolean checkLocal) { + return callTool("fetch_template", params( + "name", Objects.requireNonNull(name, "name"), + "out_path", Objects.requireNonNull(outPath, "outPath"), + "check_local", Boolean.valueOf(checkLocal))); + } + + public ComfyMcpCallResult nodes(String action, Map options) { + Map args = options == null + ? new LinkedHashMap() + : new LinkedHashMap(options); + args.put("action", action == null ? "search" : action); + return callTool("nodes", args); + } + + public ComfyMcpCallResult nodeDependencies(String pack, String registryId) { + return callTool("node_dependencies", params( + "pack", nullToEmpty(pack), + "registry_id", nullToEmpty(registryId))); + } + + public ComfyMcpCallResult workflowDeps(String workflowPath) { + return callTool("workflow_deps", + params("workflow_path", Objects.requireNonNull(workflowPath, "workflowPath"))); + } + + public ComfyMcpCallResult searchModels(String query, String folder) { + return callTool("search_models", params( + "query", nullToEmpty(query), + "folder", nullToEmpty(folder))); + } + + public ComfyMcpCallResult downloadModel(String url, String relativePath, + String filename, boolean wait, double timeoutSeconds) { + Map args = params( + "url", Objects.requireNonNull(url, "url"), + "wait", Boolean.valueOf(wait), + "timeout_seconds", Double.valueOf(timeoutSeconds)); + putIfNotNull(args, "relative_path", relativePath); + putIfNotNull(args, "filename", filename); + return callTool("download_model", args); + } + + public ComfyMcpCallResult download(String action, String downloadId, Double timeoutSeconds) { + Map args = params("action", action == null ? "status" : action); + putIfNotNull(args, "download_id", downloadId); + putIfNotNull(args, "timeout_seconds", timeoutSeconds); + return callTool("download", args); + } + + public ComfyMcpCallResult uploadFile(List paths, boolean overwrite) { + return callTool("upload_file", params( + "paths", Objects.requireNonNull(paths, "paths"), + "overwrite", Boolean.valueOf(overwrite))); + } + + public ComfyMcpCallResult validateWorkflow(String workflowPath) { + return callTool("validate_workflow", + params("workflow_path", Objects.requireNonNull(workflowPath, "workflowPath"))); + } + + public ComfyMcpCallResult listWorkflowSlots(String workflowPath) { + return callTool("list_workflow_slots", + params("workflow_path", Objects.requireNonNull(workflowPath, "workflowPath"))); + } + + public ComfyMcpCallResult listWorkflowNotes(String workflowPath) { + return callTool("list_workflow_notes", + params("workflow_path", Objects.requireNonNull(workflowPath, "workflowPath"))); + } + + public ComfyMcpCallResult setWorkflowSlot(String workflowPath, List overrides, boolean stdout) { + return callTool("set_workflow_slot", params( + "workflow_path", Objects.requireNonNull(workflowPath, "workflowPath"), + "overrides", Objects.requireNonNull(overrides, "overrides"), + "stdout", Boolean.valueOf(stdout))); + } + + public ComfyMcpCallResult varyWorkflow(String workflowPath, List slots, String outDir) { + Map args = params( + "workflow_path", Objects.requireNonNull(workflowPath, "workflowPath"), + "slots", Objects.requireNonNull(slots, "slots")); + putIfNotNull(args, "out_dir", outDir); + return callTool("vary_workflow", args); + } + + // ---- state/lifecycle --------------------------------------------------- + + public String getServerName() { return serverName; } + public String getServerVersion() { return serverVersion; } + public boolean isClosed() { return closed.get(); } + public boolean isConnected() { return initialized.get() && !closed.get(); } + int pendingRequestCount() { return pendingRpcs.size(); } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) { + return; } - boolean isError = result.path("isError").asBoolean(false); - return new ComfyMcpCallResult(text.toString(), isError, result); + initialized.set(false); + failAllPending(new ComfyException("comfy mcp client closed")); + cleanupTransport(); + timer.shutdownNow(); } - private CompletableFuture request(String method, Map params) { - long id = rpcIds.incrementAndGet(); + // ---- transport internals ---------------------------------------------- + + private CompletableFuture request(String method, Map params, + long timeoutMillis, String what) { + ensureTransportOpen(); + final long id = rpcIds.incrementAndGet(); Map payload = new LinkedHashMap(); payload.put("jsonrpc", "2.0"); payload.put("id", Long.valueOf(id)); payload.put("method", method); payload.put("params", params); - CompletableFuture future = new CompletableFuture(); + + final CompletableFuture future = new CompletableFuture(); pendingRpcs.put(Long.valueOf(id), future); - writeJson(payload); + + try { + writeJson(payload); + } catch (RuntimeException e) { + pendingRpcs.remove(Long.valueOf(id), future); + future.completeExceptionally(e); + throw e; + } + + final ScheduledFuture guard; + try { + guard = timeoutMillis <= 0 ? null : timer.schedule(() -> { + if (pendingRpcs.remove(Long.valueOf(id), future)) { + future.completeExceptionally(new ComfyException( + "comfy mcp " + what + " timed out after " + timeoutMillis + " ms")); + } + }, timeoutMillis, TimeUnit.MILLISECONDS); + } catch (RejectedExecutionException e) { + pendingRpcs.remove(Long.valueOf(id), future); + future.completeExceptionally(new ComfyException("comfy mcp timer is closed", e)); + return future; + } + + future.whenComplete((value, error) -> { + pendingRpcs.remove(Long.valueOf(id), future); + if (guard != null) { + guard.cancel(false); + } + }); return future; } @@ -337,20 +499,19 @@ private void notify(String method, Map params) { } private void writeJson(Map payload) { - if (closed.get()) { - throw new ComfyException("comfy mcp client is closed"); - } + ensureTransportOpen(); String line; try { line = mapper.writeValueAsString(payload); } catch (Exception e) { throw new ComfyException("comfy mcp RPC serialization failed", e); } - PrintWriter writer = stdin; - if (writer == null) { - throw new ComfyException("comfy mcp client is not connected"); - } - synchronized (this) { + + synchronized (writeLock) { + PrintWriter writer = stdin; + if (writer == null) { + throw new ComfyException("comfy mcp client is not connected"); + } writer.println(line); if (writer.checkError()) { throw new ComfyException("comfy mcp stdin write failed (server exited?)"); @@ -358,44 +519,82 @@ private void writeJson(Map payload) { } } - private void readLoop() { - try (BufferedReader reader = - new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { - String line; - while ((line = reader.readLine()) != null) { + private void readLoop(Process child) { + try (Reader reader = new InputStreamReader(child.getInputStream(), StandardCharsets.UTF_8)) { + String frame; + while ((frame = readBoundedLine(reader)) != null) { if (closed.get()) { return; } - if (line.trim().isEmpty()) { + if (frame.trim().isEmpty()) { continue; } - if (line.length() > effectiveMaxFrameChars()) { - log.warn("comfy mcp frame over cap, tearing transport down"); - failAllPending(new ComfyException( - "comfy mcp frame exceeded maxFrameChars=" + config.getMaxFrameChars())); - process.destroy(); - return; - } - handleFrame(line); + handleFrame(frame); + } + if (!closed.get() && process == child) { + failAllPending(new ComfyException("comfy mcp stdout closed (server exited)")); } - failAllPending(new ComfyException("comfy mcp stdout closed (server exited)")); + } catch (FrameTooLargeException e) { + failAllPending(new ComfyException( + "comfy mcp frame exceeded maxFrameChars=" + config.getMaxFrameChars(), e)); + child.destroy(); } catch (IOException e) { - if (!closed.get()) { + if (!closed.get() && process == child) { failAllPending(new ComfyException("comfy mcp stdout read failed", e)); } } } + /** Drains stderr without logging content, because child logs may contain secrets/paths. */ + private void drainStderr(Process child) { + long chars = 0L; + try (Reader reader = new InputStreamReader(child.getErrorStream(), StandardCharsets.UTF_8)) { + char[] buffer = new char[4096]; + int n; + while ((n = reader.read(buffer)) >= 0) { + chars += n; + } + } catch (IOException e) { + if (!closed.get() && process == child) { + log.debug("comfy-mcp stderr drain ended with {}", e.getClass().getSimpleName()); + } + } + log.debug("comfy-mcp stderr drained chars={}", chars); + } + + private String readBoundedLine(Reader reader) throws IOException { + StringBuilder line = new StringBuilder(); + int max = config.getMaxFrameChars() <= 0 ? Integer.MAX_VALUE : config.getMaxFrameChars(); + for (;;) { + int ch = reader.read(); + if (ch < 0) { + return line.length() == 0 ? null : line.toString(); + } + if (ch == '\n') { + return line.toString(); + } + if (ch == '\r') { + continue; + } + if (line.length() >= max) { + throw new FrameTooLargeException(); + } + line.append((char) ch); + } + } + private void handleFrame(String frame) { JsonNode node; try { node = mapper.readTree(frame); - } catch (Exception ex) { - log.warn("Ignored non-JSON frame from comfy mcp"); + } catch (Exception e) { + log.warn("Ignored non-JSON frame from comfy-mcp"); return; } + if (node.hasNonNull("id")) { - CompletableFuture pending = pendingRpcs.remove(Long.valueOf(node.get("id").asLong())); + Long id = Long.valueOf(node.get("id").asLong()); + CompletableFuture pending = pendingRpcs.remove(id); if (pending == null) { return; } @@ -407,48 +606,181 @@ private void handleFrame(String frame) { } return; } - log.debug("Ignored comfy mcp notification: method={}", node.path("method").asText("")); + log.debug("Ignored comfy-mcp notification: method={}", node.path("method").asText("")); } - private void scheduleTimeout(CompletableFuture future, long timeoutMillis, String what) { - if (timeoutMillis <= 0) { - return; + private ComfyMcpCallResult toCallResult(JsonNode result) { + StringBuilder text = new StringBuilder(); + List contents = new ArrayList(); + int cap = config.getMaxContentChars() <= 0 ? Integer.MAX_VALUE : config.getMaxContentChars(); + boolean truncated = false; + + for (JsonNode content : result.path("content")) { + String type = content.path("type").asText(""); + String piece = content.hasNonNull("text") ? content.path("text").asText() : null; + contents.add(new ComfyMcpContent( + type, + piece, + content.hasNonNull("mimeType") ? content.path("mimeType").asText() : null, + content.hasNonNull("data") ? content.path("data").asText() : null, + content.hasNonNull("uri") ? content.path("uri").asText() : null, + content)); + + if (piece == null || !"text".equals(type)) { + continue; + } + int remaining = cap - text.length(); + if (remaining <= 0) { + truncated = true; + continue; + } + if (piece.length() > remaining) { + text.append(piece, 0, remaining); + truncated = true; + } else { + text.append(piece); + } } - java.util.concurrent.ScheduledFuture guard = timer.schedule(() -> future - .completeExceptionally(new ComfyException("comfy mcp " + what + " timed out after " - + timeoutMillis + " ms")), timeoutMillis, TimeUnit.MILLISECONDS); - future.whenComplete((r, error) -> guard.cancel(false)); + + return new ComfyMcpCallResult( + text.toString(), + result.path("isError").asBoolean(false), + result, + contents, + truncated); } - private JsonNode await(CompletableFuture future, long timeoutMillis, String what) { - scheduleTimeout(future, timeoutMillis, what); + private JsonNode await(CompletableFuture future, String what) { try { return future.get(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new ComfyException("comfy mcp " + what + " interrupted", e); } catch (ExecutionException e) { - Throwable cause = e.getCause() == null ? e : e.getCause(); - if (cause instanceof ComfyException) { - throw (ComfyException) cause; - } - if (cause instanceof RuntimeException) { - throw (RuntimeException) cause; - } - throw new ComfyException("comfy mcp " + what + " failed", cause); + throw propagate("comfy mcp " + what + " failed", e.getCause()); } } + private ComfyMcpCallResult awaitCall(CompletableFuture future, String what) { + try { + return future.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ComfyException("comfy mcp " + what + " interrupted", e); + } catch (ExecutionException e) { + throw propagate("comfy mcp " + what + " failed", e.getCause()); + } + } + + private RuntimeException propagate(String message, Throwable cause) { + Throwable actual = cause == null ? new ComfyException(message) : cause; + if (actual instanceof ComfyException) { + return (ComfyException) actual; + } + if (actual instanceof RuntimeException) { + return (RuntimeException) actual; + } + return new ComfyException(message, actual); + } + private void failAllPending(ComfyException error) { for (Map.Entry> entry : pendingRpcs.entrySet()) { - CompletableFuture future = pendingRpcs.remove(entry.getKey()); - if (future != null) { - future.completeExceptionally(error); + if (pendingRpcs.remove(entry.getKey(), entry.getValue())) { + entry.getValue().completeExceptionally(error); + } + } + } + + private synchronized void cleanupTransport() { + initialized.set(false); + + PrintWriter writer = stdin; + stdin = null; + if (writer != null) { + writer.close(); + } + + Process child = process; + process = null; + if (child != null) { + child.destroy(); + try { + if (config.getShutdownTimeoutMillis() > 0 + && !child.waitFor(config.getShutdownTimeoutMillis(), TimeUnit.MILLISECONDS)) { + child.destroyForcibly(); + child.waitFor(config.getShutdownTimeoutMillis(), TimeUnit.MILLISECONDS); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + child.destroyForcibly(); } } + + Thread out = stdoutThread; + Thread err = stderrThread; + stdoutThread = null; + stderrThread = null; + interruptAndJoin(out); + interruptAndJoin(err); + } + + private void interruptAndJoin(Thread thread) { + if (thread == null || thread == Thread.currentThread()) { + return; + } + thread.interrupt(); + try { + thread.join(Math.max(100L, config.getShutdownTimeoutMillis())); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private void ensureConnected() { + if (closed.get()) { + throw new IllegalStateException("comfy mcp client is closed"); + } + if (!initialized.get()) { + throw new IllegalStateException("comfy mcp client is not connected"); + } + ensureTransportOpen(); + } + + private void ensureTransportOpen() { + if (closed.get()) { + throw new ComfyException("comfy mcp client is closed"); + } + Process child = process; + if (child == null || stdin == null) { + throw new ComfyException("comfy mcp client is not connected"); + } + } + + private static Thread daemon(String name, Runnable task) { + Thread thread = new Thread(task, name); + thread.setDaemon(true); + return thread; + } + + private static Map params(Object... keyValues) { + Map result = new LinkedHashMap(); + for (int i = 0; i + 1 < keyValues.length; i += 2) { + result.put(String.valueOf(keyValues[i]), keyValues[i + 1]); + } + return result; + } + + private static void putIfNotNull(Map target, String key, Object value) { + if (value != null) { + target.put(key, value); + } + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; } - private int effectiveMaxFrameChars() { - return config.getMaxFrameChars() <= 0 ? Integer.MAX_VALUE : config.getMaxFrameChars(); + private static final class FrameTooLargeException extends IOException { + private static final long serialVersionUID = 1L; } } diff --git a/src/main/java/io/github/easy4j/comfy/mcp/ComfyMcpConfig.java b/src/main/java/io/github/easy4j/comfy/mcp/ComfyMcpConfig.java index 9d4499b..62919b5 100644 --- a/src/main/java/io/github/easy4j/comfy/mcp/ComfyMcpConfig.java +++ b/src/main/java/io/github/easy4j/comfy/mcp/ComfyMcpConfig.java @@ -2,16 +2,6 @@ * Copyright (c) 2018-present, easy-4-java (https://github.com/easy-4-java). * * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package io.github.easy4j.comfy.mcp; @@ -20,74 +10,45 @@ import lombok.Data; -/** - * Configuration for the comfy-mcp route. - * - *

{@code comfy-mcp} speaks MCP over stdio (newline-delimited JSON-RPC). - * The SDK spawns it as a child process and drives {@code tools/list} / - * {@code tools/call} through that channel — no sockets, so this route - * works on every JDK line the SDK supports.

- * - * @author Loong Wan - * @since 1.0.0 - * @see ComfyMcpClient - */ +/** Configuration for the local {@code comfy-mcp} stdio route. */ @Data public class ComfyMcpConfig { - /** - * Name or absolute path of the MCP server executable - * (e.g. {@code comfy-mcp} from PyPI, or an absolute venv path). - */ private String localExecutable = "comfy-mcp"; - - /** Extra arguments forwarded to the server executable. */ private String[] mcpArgs; - - /** - * Extra environment variables for the server process (e.g. - * {@code COMFY_BIN} pointing at the workspace's {@code comfy} binary); - * merged over the parent environment. Required when {@code comfy} is not - * on the PATH the SDK's process sees. - */ private Map environment; - - /** MCP protocol version advertised in {@code initialize}. */ private String protocolVersion = "2024-11-05"; - - /** Client name advertised in {@code initialize}. */ private String clientName = "comfy-java-sdk"; - - /** Client version advertised in {@code initialize}. */ private String clientVersion = "1.0.0"; - - /** Timeout in milliseconds for process startup plus the MCP {@code initialize} handshake. */ private int connectTimeoutMillis = 10_000; - - /** Upper bound in milliseconds for one {@code tools/call} (generation tools can run for minutes). */ private int readTimeoutMillis = 900_000; + private int shutdownTimeoutMillis = 2_000; /** - * Hard cap in characters for one JSON-RPC frame on the wire; a frame - * exceeding it tears the transport down. {@code <= 0} means unbounded. - * Defaults to 1 MiB characters. + * Hard frame limit before JSON parsing. 16 MiB allows normal inline image + * responses while still bounding a malicious/broken single-line frame. + * {@code <= 0} means unbounded. */ - private int maxFrameChars = 1_048_576; + private int maxFrameChars = 16 * 1024 * 1024; - /** - * Hard cap in characters for the text content accumulated per - * {@code tools/call}; excess content items are truncated with a warning. - * {@code <= 0} means unbounded. Defaults to 1 MiB characters. - */ - private int maxContentChars = 1_048_576; + /** Text aggregation cap for one tools/call. {@code <= 0} means unbounded. */ + private int maxContentChars = 4 * 1024 * 1024; - /** - * Validates the configuration. - * - * @throws NullPointerException when the executable or client name is {@code null}. - */ public void validate() { Objects.requireNonNull(localExecutable, "localExecutable"); Objects.requireNonNull(clientName, "clientName"); + Objects.requireNonNull(protocolVersion, "protocolVersion"); + if (localExecutable.trim().isEmpty()) { + throw new IllegalStateException("localExecutable must not be blank"); + } + if (connectTimeoutMillis <= 0) { + throw new IllegalStateException("connectTimeoutMillis must be > 0"); + } + if (readTimeoutMillis <= 0) { + throw new IllegalStateException("readTimeoutMillis must be > 0"); + } + if (shutdownTimeoutMillis < 0) { + throw new IllegalStateException("shutdownTimeoutMillis must be >= 0"); + } } } diff --git a/src/main/java/io/github/easy4j/comfy/mcp/ComfyMcpContent.java b/src/main/java/io/github/easy4j/comfy/mcp/ComfyMcpContent.java new file mode 100644 index 0000000..a7d2777 --- /dev/null +++ b/src/main/java/io/github/easy4j/comfy/mcp/ComfyMcpContent.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2018-present, easy-4-java (https://github.com/easy-4-java). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + */ +package io.github.easy4j.comfy.mcp; + +import lombok.Data; +import tools.jackson.databind.JsonNode; + +/** + * One MCP content item. Known common fields are projected for convenience and + * {@link #raw} preserves new MCP content variants without SDK upgrades. + */ +@Data +public class ComfyMcpContent { + private final String type; + private final String text; + private final String mimeType; + private final String data; + private final String uri; + private final JsonNode raw; +} diff --git a/src/main/java/io/github/easy4j/comfy/model/ComfyCliEnvelope.java b/src/main/java/io/github/easy4j/comfy/model/ComfyCliEnvelope.java new file mode 100644 index 0000000..c502e79 --- /dev/null +++ b/src/main/java/io/github/easy4j/comfy/model/ComfyCliEnvelope.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2018-present, easy-4-java (https://github.com/easy-4-java). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + */ +package io.github.easy4j.comfy.model; + +import lombok.Data; +import tools.jackson.databind.JsonNode; + +/** + * Uniform JSON envelope emitted by {@code comfy --json }. + * + *

The payload intentionally keeps {@code data} and {@code error} as + * {@link JsonNode}: the command tree is self-describing via + * {@code comfy --json discover} and evolves independently of this SDK.

+ */ +@Data +public class ComfyCliEnvelope { + private boolean ok; + private String command; + private String version; + private String where; + private JsonNode data; + private JsonNode error; +} diff --git a/src/test/java/io/github/easy4j/comfy/ComfyClientConfigTest.java b/src/test/java/io/github/easy4j/comfy/ComfyClientConfigTest.java index 2d976bf..12e3c0e 100644 --- a/src/test/java/io/github/easy4j/comfy/ComfyClientConfigTest.java +++ b/src/test/java/io/github/easy4j/comfy/ComfyClientConfigTest.java @@ -1,17 +1,5 @@ /* - * Copyright (c) 2018-present, easy-4-java (https://github.com/easy-4-java). - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Copyright (c) 2018-present, easy-4-java. */ package io.github.easy4j.comfy; @@ -20,39 +8,29 @@ import org.junit.jupiter.api.Test; -/** Unit tests for {@link ComfyClientConfig} defaults and validation. @since 1.0.0 */ class ComfyClientConfigTest { @Test - void shouldExposeSensibleDefaults() { + void shouldExposeProductionSafeDefaults() { ComfyClientConfig config = new ComfyClientConfig(); assertEquals("comfy", config.getLocalExecutable()); assertEquals(600, config.getLocalTimeoutSeconds()); assertEquals(5, config.getLocalProbeTimeoutSeconds()); + assertEquals(16 * 1024 * 1024, config.getMaxOutputBytes()); } @Test - void shouldAcceptValidWhereValues() { - ComfyClientConfig config = new ComfyClientConfig(); - config.setDefaultWhere("local"); - config.validate(); - config.setDefaultWhere("cloud"); - config.validate(); - } - - @Test - void shouldRejectInvalidWhereValues() { + void shouldValidateRoutingAndTimeouts() { ComfyClientConfig config = new ComfyClientConfig(); config.setDefaultWhere("bogus"); + assertThrows(IllegalArgumentException.class, config::validate); + + config = new ComfyClientConfig(); + config.setLocalTimeoutSeconds(0); assertThrows(IllegalStateException.class, config::validate); - } - @Test - void shouldAcceptEnvironmentOverrides() { - ComfyClientConfig config = new ComfyClientConfig(); - config.getEnvironment(); // null by default - config.setEnvironment(java.util.Collections.singletonMap("COMFY_API_KEY", "k")); - config.validate(); - assertEquals("k", config.getEnvironment().get("COMFY_API_KEY")); + config = new ComfyClientConfig(); + config.setLocalProbeTimeoutSeconds(0); + assertThrows(IllegalStateException.class, config::validate); } } diff --git a/src/test/java/io/github/easy4j/comfy/ComfyClientTest.java b/src/test/java/io/github/easy4j/comfy/ComfyClientTest.java index 6d86249..e4a620d 100644 --- a/src/test/java/io/github/easy4j/comfy/ComfyClientTest.java +++ b/src/test/java/io/github/easy4j/comfy/ComfyClientTest.java @@ -1,17 +1,5 @@ /* - * Copyright (c) 2018-present, easy-4-java (https://github.com/easy-4-java). - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Copyright (c) 2018-present, easy-4-java. */ package io.github.easy4j.comfy; @@ -21,77 +9,75 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.nio.file.Paths; + import org.junit.jupiter.api.Test; import io.github.easy4j.comfy.cli.ComfyCli; +import io.github.easy4j.comfy.model.ComfyCliEnvelope; import tools.jackson.databind.JsonNode; -import io.github.easy4j.comfy.cli.ComfyCliExecutor; -/** - * Unit tests for {@link ComfyClient} validation and delegation. - * - * @since 1.0.0 - */ class ComfyClientTest { - private static ComfyClientConfig echoConfig() { + private static String resource(String name) { + return Paths.get("src", "test", "resources", name).toAbsolutePath().toString(); + } + + private static ComfyClientConfig config(String executable) { ComfyClientConfig config = new ComfyClientConfig(); - config.setLocalExecutable( - java.nio.file.Paths.get("src", "test", "resources", "comfy-echo.sh").toAbsolutePath().toString()); + config.setLocalExecutable(executable); config.setLocalTimeoutSeconds(2); return config; } - @Test - void shouldRejectNullConfig() { - assertThrows(NullPointerException.class, () -> new ComfyClient(null)); - } - - @Test - void shouldRejectInvalidConfig() { - ComfyClientConfig config = echoConfig(); - config.setDefaultWhere("bogus"); - assertThrows(IllegalStateException.class, () -> new ComfyClient(config)); - } - @Test void shouldDelegateBasics() { - try (ComfyClient client = new ComfyClient(echoConfig())) { + try (ComfyClient client = new ComfyClient(config(resource("comfy-echo.sh")))) { assertTrue(client.version().getStdout().contains("--version")); assertTrue(client.isAvailable()); - assertTrue(client.cloudLogin().getStdout().contains("cloud login")); - assertTrue(client.setup().getStdout().contains("-y")); - assertTrue(client.skillsInstall().getStdout().contains("skills install")); - assertNotNull(client.getConfig()); assertNotNull(client.cli()); + assertNotNull(client.getConfig()); + } + } + + @Test + void shouldParseGenerateJsonWithoutMutatingOptions() { + ComfyCli.GenerateOptions options = new ComfyCli.GenerateOptions().prompt("hi"); + try (ComfyClient jsonClient = new ComfyClient(config(resource("comfy-json.sh")))) { + JsonNode json = jsonClient.generateJson("flux-pro", options); + assertEquals("https://example/asset.png", json.path("data").get(0).path("url").asText()); + } + + try (ComfyClient echoClient = new ComfyClient(config(resource("comfy-echo.sh")))) { + String args = echoClient.cli().generate("flux-pro", options).getStdout(); + assertFalse(args.contains("--json"), "generateJson must not mutate caller options"); } } @Test - void shouldRaiseWhenGenerateJsonPrintsNonJson() { - // The echo fixture prints its argument list, which is not JSON — - // generateJson must surface that as ComfyException. - try (ComfyClient client = new ComfyClient(echoConfig())) { - assertThrows(ComfyException.class, - () -> client.generateJson("flux-pro", new ComfyCli.GenerateOptions().prompt("hi"))); + void shouldParseUniformCliEnvelope() { + try (ComfyClient client = new ComfyClient(config(resource("comfy-envelope.sh")))) { + ComfyCliEnvelope envelope = client.environment(); + assertTrue(envelope.isOk()); + assertEquals("env", envelope.getCommand()); + assertEquals("local", envelope.getWhere()); + assertTrue(envelope.getData().path("running").asBoolean()); } } @Test - void shouldParseGenerateJsonOutput() throws Exception { - ComfyClientConfig config = echoConfig(); - config.setLocalExecutable( - java.nio.file.Paths.get("src", "test", "resources", "comfy-json.sh").toAbsolutePath().toString()); + void shouldRejectTruncatedJsonEnvelope() { + ComfyClientConfig config = config(resource("comfy-envelope.sh")); + config.setMaxOutputBytes(12); try (ComfyClient client = new ComfyClient(config)) { - JsonNode json = client.generateJson("flux-pro", - new ComfyCli.GenerateOptions().prompt("hi").json(true)); - assertEquals("https://example/asset.png", json.path("data").get(0).path("url").asText()); + assertThrows(ComfyException.class, client::environment); } } @Test - void shouldCloseWithoutError() { - ComfyClient client = new ComfyClient(echoConfig()); - client.close(); + void shouldRejectInvalidConfig() { + ComfyClientConfig config = config(resource("comfy-echo.sh")); + config.setDefaultWhere("bogus"); + assertThrows(IllegalArgumentException.class, () -> new ComfyClient(config)); } } diff --git a/src/test/java/io/github/easy4j/comfy/cli/ComfyCliExecutorTest.java b/src/test/java/io/github/easy4j/comfy/cli/ComfyCliExecutorTest.java index da8885a..3ded163 100644 --- a/src/test/java/io/github/easy4j/comfy/cli/ComfyCliExecutorTest.java +++ b/src/test/java/io/github/easy4j/comfy/cli/ComfyCliExecutorTest.java @@ -1,17 +1,5 @@ /* - * Copyright (c) 2018-present, easy-4-java (https://github.com/easy-4-java). - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Copyright (c) 2018-present, easy-4-java. */ package io.github.easy4j.comfy.cli; @@ -19,161 +7,117 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.nio.file.Paths; +import java.util.LinkedHashMap; +import java.util.Map; + import org.junit.jupiter.api.Test; import io.github.easy4j.comfy.ComfyClientConfig; -/** - * Unit tests for {@link ComfyCliExecutor}: raw argv passing, exit-code - * preservation, stream capture, stdin piping and the watchdog timeout. - * - * @since 1.0.0 - */ class ComfyCliExecutorTest { - /** Absolute path of the argument-echoing fixture script (surefire runs from the module base dir). */ private static final String ECHO = - java.nio.file.Paths.get("src", "test", "resources", "comfy-echo.sh").toAbsolutePath().toString(); + Paths.get("src", "test", "resources", "comfy-echo.sh").toAbsolutePath().toString(); + private static final String SLOW = + Paths.get("src", "test", "resources", "comfy-slow.sh").toAbsolutePath().toString(); private ComfyClientConfig configFor(String executable) { ComfyClientConfig config = new ComfyClientConfig(); config.setLocalExecutable(executable); - // Short timeouts so failing tests stay fast. config.setLocalTimeoutSeconds(2); - config.setLocalProbeTimeoutSeconds(2); + config.setLocalProbeTimeoutSeconds(1); return config; } @Test void shouldExecuteSuccessfullyWithCapturedStdout() { - ComfyCliExecutor executor = new ComfyCliExecutor(configFor(ECHO)); - - ComfyCliResult result = executor.execute("hello", "world"); - + ComfyCliResult result = new ComfyCliExecutor(configFor(ECHO)).execute("hello", "world"); assertEquals(0, result.getExitCode()); assertTrue(result.isSuccess()); assertEquals("hello world", result.getStdout()); } @Test - void shouldPassArgumentsRawWithoutEmbeddedQuotes() { - ComfyCliExecutor executor = new ComfyCliExecutor(configFor(ECHO)); - - ComfyCliResult result = executor.execute("Write a failing test", "--model", "kimi k2"); - - assertEquals("Write a failing test --model kimi k2", result.getStdout(), - "multi-word arguments must arrive without embedded literal quotes"); + void shouldPassArgumentsRawWithoutShellInterpretation() { + ComfyCliResult result = new ComfyCliExecutor(configFor(ECHO)) + .execute("a b", ";rm -rf /", "$(whoami)"); + assertEquals("a b ;rm -rf / $(whoami)", result.getStdout()); } @Test void shouldPreserveRealExitCodeAndStreamsOnNonZeroExit() { - ComfyCliExecutor executor = new ComfyCliExecutor(configFor("/bin/sh")); - - ComfyCliResult result = executor.execute("-c", "echo out-marker; echo err-marker 1>&2; exit 7"); - + ComfyCliResult result = new ComfyCliExecutor(configFor("/bin/sh")) + .execute("-c", "echo out-marker; echo err-marker 1>&2; exit 7"); assertEquals(7, result.getExitCode()); assertFalse(result.isSuccess()); - assertTrue(result.getStdout().contains("out-marker"), "stdout must survive a non-zero exit"); - assertTrue(result.getStderr().contains("err-marker"), "stderr must survive a non-zero exit"); - } - - @Test - void shouldReturnIoExceptionMessageWhenExecutableMissing() { - ComfyCliExecutor executor = new ComfyCliExecutor(configFor("/nonexistent/path/to/comfy")); - - ComfyCliResult result = executor.execute("--version"); - - assertEquals(-1, result.getExitCode()); - assertFalse(result.isSuccess()); - assertTrue(result.getStderr() != null && !result.getStderr().isEmpty()); + assertTrue(result.getStdout().contains("out-marker")); + assertTrue(result.getStderr().contains("err-marker")); } @Test - void shouldDecodeUtf8OutputRegardlessOfPlatformCharset() { - // POSIX printf octal escapes emit 你好 as raw UTF-8 bytes; with a - // platform-default-charset decode this corrupts on C-locale JVMs. - // NOTE: the backslashes are doubled in Java source so the shell - // receives single ones — an octal escape like \344 must be written - // \\344 here or the compiler eats it at compile time. - ComfyCliExecutor executor = new ComfyCliExecutor(configFor("/bin/sh")); - - ComfyCliResult result = executor.execute("-c", "printf '\\344\\275\\240\\345\\245\\275'"); - + void shouldDecodeUtf8Output() { + ComfyCliResult result = new ComfyCliExecutor(configFor("/bin/sh")) + .execute("-c", "printf '\\344\\275\\240\\345\\245\\275'"); assertEquals("你好", result.getStdout()); } @Test - void shouldIgnoreNullArguments() { - ComfyCliExecutor executor = new ComfyCliExecutor(configFor(ECHO)); - - ComfyCliResult result = executor.execute("hello", null, "world"); - - assertEquals(0, result.getExitCode()); - assertEquals("hello world", result.getStdout()); - } - - @Test - void shouldFeedStdinToChildProcess() { - // `cat` with no file arguments echoes its standard input verbatim, - // which is how stdin-consuming CLI forms receive their payload. - ComfyCliExecutor executor = new ComfyCliExecutor(configFor("/bin/cat")); - - ComfyCliResult result = executor.executeWithStdin("secret-api-key"); - - assertEquals(0, result.getExitCode()); - assertEquals("secret-api-key", result.getStdout()); - } - - @Test - void shouldExecuteWithoutStdinAsBefore() { - ComfyCliExecutor executor = new ComfyCliExecutor(configFor(ECHO)); - - assertEquals("plain", executor.executeWithStdin(null, "plain").getStdout()); - assertEquals("plain", executor.executeWithStdin("", "plain").getStdout()); + void shouldFeedStdinAndCloseIt() { + ComfyCliResult result = new ComfyCliExecutor(configFor("/bin/cat")) + .executeWithStdin("payload"); + assertEquals("payload", result.getStdout()); } @Test void shouldInjectEnvironmentIntoChildProcess() { ComfyClientConfig config = configFor("/usr/bin/env"); - java.util.Map env = new java.util.LinkedHashMap(); + Map env = new LinkedHashMap(); env.put("COMFY_PROBE_MARKER", "injected-ok"); config.setEnvironment(env); - - ComfyCliResult result = executor(config).execute(); - - assertTrue(result.getStdout().contains("COMFY_PROBE_MARKER=injected-ok"), - "child environment must carry the injected variables"); - } - - private ComfyCliExecutor executor(ComfyClientConfig config) { - return new ComfyCliExecutor(config); + assertTrue(new ComfyCliExecutor(config).execute().getStdout() + .contains("COMFY_PROBE_MARKER=injected-ok")); } @Test - void shouldReportSuccessFromProbeWhenExecutableWorks() { - ComfyCliExecutor executor = new ComfyCliExecutor(configFor(ECHO)); - - assertTrue(executor.probe()); + void shouldUseDedicatedProbeTimeout() { + ComfyClientConfig config = configFor(SLOW); + config.setLocalTimeoutSeconds(30); + config.setLocalProbeTimeoutSeconds(1); + long started = System.nanoTime(); + assertFalse(new ComfyCliExecutor(config).probe()); + long millis = (System.nanoTime() - started) / 1_000_000L; + assertTrue(millis < 4_000L, "probe must not inherit the 30 second execution timeout"); } @Test - void shouldReportFailureFromProbeWhenExecutableMissing() { - ComfyCliExecutor executor = new ComfyCliExecutor(configFor("/nonexistent/path/to/comfy")); - - assertFalse(executor.probe()); + void shouldBoundCapturedOutput() { + ComfyClientConfig config = configFor("/bin/sh"); + config.setMaxOutputBytes(8); + ComfyCliResult result = new ComfyCliExecutor(config) + .execute("-c", "printf '12345678901234567890'; printf 'abcdefghijklmnop' 1>&2"); + assertEquals("12345678", result.getStdout()); + assertEquals("abcdefgh", result.getStderr()); + assertTrue(result.isStdoutTruncated()); + assertTrue(result.isStderrTruncated()); + assertTrue(result.isTruncated()); } @Test void shouldTimeoutOnHangingProcess() { - // Use a short timeout and a command that sleeps for a long time. ComfyClientConfig config = configFor("/bin/sh"); config.setLocalTimeoutSeconds(1); - ComfyCliExecutor executor = new ComfyCliExecutor(config); - - ComfyCliResult result = executor.execute("-c", "sleep 30"); + ComfyCliResult result = new ComfyCliExecutor(config).execute("-c", "sleep 30"); + assertEquals(-1, result.getExitCode()); + assertTrue(result.isTimeout()); + } + @Test + void shouldReturnMessageWhenExecutableMissing() { + ComfyCliResult result = new ComfyCliExecutor(configFor("/nonexistent/path/to/comfy")) + .execute("--version"); assertEquals(-1, result.getExitCode()); assertFalse(result.isSuccess()); - assertTrue(result.isTimeout(), "stderr must carry the timeout notice"); + assertTrue(result.getStderr() != null && !result.getStderr().isEmpty()); } } diff --git a/src/test/java/io/github/easy4j/comfy/cli/ComfyCliTest.java b/src/test/java/io/github/easy4j/comfy/cli/ComfyCliTest.java index 4900d95..5fad610 100644 --- a/src/test/java/io/github/easy4j/comfy/cli/ComfyCliTest.java +++ b/src/test/java/io/github/easy4j/comfy/cli/ComfyCliTest.java @@ -1,162 +1,146 @@ /* - * Copyright (c) 2018-present, easy-4-java (https://github.com/easy-4-java). - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Copyright (c) 2018-present, easy-4-java. */ package io.github.easy4j.comfy.cli; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.nio.file.Paths; + import org.junit.jupiter.api.Test; import io.github.easy4j.comfy.ComfyClientConfig; -/** - * Contract tests for {@link ComfyCli} driven without a socket: outgoing - * argument lists are verified through the echo fixture. - * - * @since 1.0.0 - */ class ComfyCliTest { - /** Absolute path of the argument-echoing fixture script (surefire runs from the module base dir). */ private static final String ECHO = - java.nio.file.Paths.get("src", "test", "resources", "comfy-echo.sh").toAbsolutePath().toString(); + Paths.get("src", "test", "resources", "comfy-echo.sh").toAbsolutePath().toString(); - private static ComfyClientConfig echoConfig() { + private static ComfyCli cli() { ComfyClientConfig config = new ComfyClientConfig(); config.setLocalExecutable(ECHO); config.setLocalTimeoutSeconds(2); - return config; - } - - private static ComfyCli echoCli() { - return new ComfyCli(echoConfig(), new ComfyCliExecutor(echoConfig())); + return new ComfyCli(config, new ComfyCliExecutor(config)); } @Test - void shouldExposeExecutor() { - assertNotNull(echoCli().executor()); + void shouldMapGlobalAndEnvironmentCommands() { + assertTrue(cli().discoverJson().getStdout().contains("--json discover")); + assertTrue(cli().executeJsonStream("jobs", "watch", "p1").getStdout() + .contains("--json-stream jobs watch p1")); + assertTrue(cli().which().getStdout().contains("which")); + assertTrue(cli().env().getStdout().contains("env")); + assertTrue(cli().outdated().getStdout().contains("outdated")); + assertTrue(cli().systemStats().getStdout().contains("system-stats")); + assertTrue(cli().freeMemory().getStdout().contains("free-memory")); } @Test - void shouldDelegateVersionAndHelp() { - assertTrue(echoCli().version().getStdout().contains("--version")); - assertTrue(echoCli().help().getStdout().contains("--help")); - assertTrue(echoCli().installCompletion().getStdout().contains("--install-completion")); - assertTrue(echoCli().discoverJson().getStdout().contains("--json discover")); + void shouldMapSetupCloudAndRouting() { + assertTrue(cli().setupYes().getStdout().contains("setup -y")); + assertTrue(cli().cloudLoginNoBrowser().getStdout().contains("cloud login --no-browser")); + assertTrue(cli().cloudWhoami().getStdout().contains("cloud whoami")); + assertTrue(cli().cloudLogout().getStdout().contains("cloud logout")); + assertTrue(cli().cloudStatus().getStdout().contains("cloud status")); + assertTrue(cli().cloudSetBaseUrl("https://example.test").getStdout() + .contains("cloud set-base-url https://example.test")); + assertTrue(cli().setDefaultWhere("local").getStdout().contains("set-default --where local")); + assertThrows(IllegalArgumentException.class, () -> cli().setDefaultWhere("bogus")); } @Test - void shouldDelegateSetupAndCloudAuth() { - assertTrue(echoCli().setup().getStdout().contains("setup")); - assertTrue(echoCli().setupYes().getStdout().contains("-y")); - assertTrue(echoCli().cloudLogin().getStdout().contains("cloud login")); - assertTrue(echoCli().cloudWhoami().getStdout().contains("cloud whoami")); + void shouldMapLifecycle() { + assertTrue(cli().install("--here").getStdout().contains("install --here")); + assertTrue(cli().launchBackground("--port", "8188").getStdout() + .contains("launch --background --port 8188")); + assertTrue(cli().stop().getStdout().contains("stop")); + assertTrue(cli().update("comfy").getStdout().contains("update comfy")); + assertTrue(cli().logs("--tail", "20").getStdout().contains("logs --tail 20")); } @Test - void shouldDelegateSetDefaultWhere() { - assertTrue(echoCli().setDefaultWhere("cloud").getStdout().contains("--where cloud")); - assertThrows(IllegalArgumentException.class, () -> echoCli().setDefaultWhere("bogus")); - } - - @Test - void shouldDelegateComfyUiLifecycle() { - assertTrue(echoCli().install("--here").getStdout().contains("install --here")); - assertTrue(echoCli().launch("--port", "8188").getStdout().contains("launch --port 8188")); - assertTrue(echoCli().stop().getStdout().contains("stop")); - assertTrue(echoCli().update().getStdout().contains("update")); - } - - @Test - void shouldBuildGenerateWithAllFlags() { + void shouldBuildGenerationAndValidateDynamicOptions() { ComfyCli.GenerateOptions options = new ComfyCli.GenerateOptions() - .prompt("a cat on the moon") - .width(1024).height(1024) - .download("cat.png") - .image("in.png").mask("mask.png") - .resolution("1080p").duration(5).aspectRatio("16:9") - .renderingSpeed("quality") - .async(true).json(true) - .where("cloud"); - - ComfyCliResult result = echoCli().generate("flux-pro", options); - String out = result.getStdout(); + .prompt("a cat") + .width(1024).height(768) + .duration(5) + .timeoutSeconds(90) + .where("cloud") + .option("seed", 42) + .flag("enhance-prompt"); + + String out = cli().generate("flux-pro", options).getStdout(); assertTrue(out.contains("generate flux-pro")); - assertTrue(out.contains("--prompt a cat on the moon")); + assertTrue(out.contains("--prompt a cat")); assertTrue(out.contains("--width 1024")); - assertTrue(out.contains("--height 1024")); - assertTrue(out.contains("--download cat.png")); - assertTrue(out.contains("--image in.png")); - assertTrue(out.contains("--mask mask.png")); - assertTrue(out.contains("--resolution 1080p")); - assertTrue(out.contains("--duration 5")); - assertTrue(out.contains("--aspect_ratio 16:9")); - assertTrue(out.contains("--rendering_speed quality")); - assertTrue(out.contains("--async")); - assertTrue(out.contains("--json")); - assertTrue(out.contains("--where cloud"), "explicit where must be appended"); - } + assertTrue(out.contains("--timeout 90")); + assertTrue(out.contains("--seed 42")); + assertTrue(out.contains("--enhance-prompt")); + assertTrue(out.contains("--where cloud")); - @Test - void shouldPropagateConfigDefaultWhere() { - ComfyClientConfig config = echoConfig(); - config.setDefaultWhere("local"); - ComfyCliResult result = new ComfyCli(config, new ComfyCliExecutor(config)) - .generate("seedance", new ComfyCli.GenerateOptions().prompt("hi").where(null)); - assertTrue(result.getStdout().contains("--where local")); - } - - @Test - void shouldRejectNullModelAndOptions() { - assertThrows(NullPointerException.class, () -> echoCli().generate(null, new ComfyCli.GenerateOptions())); - assertThrows(NullPointerException.class, () -> echoCli().generate("flux-pro", null)); + assertThrows(IllegalArgumentException.class, () -> new ComfyCli.GenerateOptions().where("x")); + assertThrows(IllegalArgumentException.class, () -> new ComfyCli.GenerateOptions().option("bad flag", 1)); } @Test - void shouldDelegateGenerateVariants() { - assertTrue(echoCli().generateList("text-to-video", "kling").getStdout() - .contains("--category text-to-video --partner kling")); - assertTrue(echoCli().generateSchema("flux-kontext").getStdout().contains("schema flux-kontext")); - assertTrue(echoCli().generateUpload("in.png").getStdout().contains("upload in.png")); - String resume = echoCli().generateResume("luma", "job-1", "out.mp4").getStdout(); - assertTrue(resume.contains("resume luma job-1 --download out.mp4")); + void shouldMapWorkflowJobAndTemplateCommands() { + assertTrue(cli().runWorkflow("wf.json", true).getStdout() + .contains("run --workflow wf.json --wait")); + assertTrue(cli().jobsList().getStdout().contains("jobs ls")); + assertTrue(cli().jobStatus("p1").getStdout().contains("jobs status p1")); + assertTrue(cli().jobsWait("p1", "p2").getStdout().contains("jobs wait p1 p2")); + assertTrue(cli().jobCancel("p1").getStdout().contains("jobs cancel p1")); + assertTrue(cli().validateWorkflow("wf.json").getStdout() + .contains("validate --workflow wf.json")); + assertTrue(cli().templatesList("image", "Text to Image").getStdout() + .contains("templates ls --type image --tag Text to Image")); + assertTrue(cli().templateFetch("basic", "out.json").getStdout() + .contains("templates fetch basic --out out.json")); } @Test - void shouldDelegateWorkflowAndDiscovery() { - assertTrue(echoCli().run("--workflow", "a.json").getStdout().contains("run --workflow a.json")); - assertTrue(echoCli().jobs().getStdout().contains("jobs")); - assertTrue(echoCli().validate("wf.json").getStdout().contains("validate wf.json")); - assertTrue(echoCli().workflow("list").getStdout().contains("workflow list")); - assertTrue(echoCli().templates().getStdout().contains("templates")); - assertTrue(echoCli().nodes("search").getStdout().contains("nodes search")); - assertTrue(echoCli().models("list").getStdout().contains("models list")); + void shouldMapWorkflowEditing() { + assertTrue(cli().workflowSlots("wf.json").getStdout().contains("workflow slots wf.json")); + assertTrue(cli().workflowSetSlot("wf.json", "6.text=a fox").getStdout() + .contains("workflow set-slot wf.json 6.text=a fox")); + assertTrue(cli().workflowVary("wf.json", "variants", "6.seed=[1,2]").getStdout() + .contains("workflow vary wf.json --slot 6.seed=[1,2] --out-dir variants")); + assertTrue(cli().workflowList().getStdout().contains("workflow list")); + assertTrue(cli().workflowGet("id1", "wf.json").getStdout() + .contains("workflow get id1 --out wf.json")); + assertTrue(cli().workflowSave("wf.json", "My Flow").getStdout() + .contains("workflow save wf.json --name My Flow")); + assertTrue(cli().workflowDelete("id1").getStdout().contains("workflow delete id1")); + assertTrue(cli().workflowCompose("pipe.yaml", "wf.json").getStdout() + .contains("workflow compose pipe.yaml -o wf.json")); + assertTrue(cli().workflowDecompose("wf.json").getStdout() + .contains("workflow decompose wf.json")); } @Test - void shouldDelegateSkills() { - assertTrue(echoCli().skillsInstall().getStdout().contains("skills install")); - assertTrue(echoCli().skillsList().getStdout().contains("skills list")); - assertTrue(echoCli().skillsStatus().getStdout().contains("skills status")); + void shouldMapDiscoveryAssetsAndManagementFamilies() { + assertTrue(cli().nodesSearch("checkpoint").getStdout().contains("nodes search checkpoint")); + assertTrue(cli().nodeShow("KSampler").getStdout().contains("nodes show KSampler")); + assertTrue(cli().modelFolders().getStdout().contains("models list-folders")); + assertTrue(cli().modelsSearch("wan", "lora").getStdout() + .contains("models search --text wan --type lora")); + assertTrue(cli().nodeInstall("pack").getStdout().contains("node install pack")); + assertTrue(cli().modelDownload("https://example/model", "models/checkpoints").getStdout() + .contains("model download --url https://example/model --relative-path models/checkpoints")); + assertTrue(cli().upload("a.png", "b.png").getStdout().contains("upload a.png b.png")); + assertTrue(cli().download("p1", "-o", "outputs").getStdout() + .contains("download p1 -o outputs")); } @Test - void shouldDelegateRawExecute() { - assertTrue(echoCli().execute("--version").getStdout().contains("--version")); + void shouldMapSkillsAndTrackingAndKeepEscapeHatch() { + assertTrue(cli().skillsInstall().getStdout().contains("skills install")); + assertTrue(cli().skillsList().getStdout().contains("skills list")); + assertTrue(cli().skillsStatus().getStdout().contains("skills status")); + assertTrue(cli().trackingDisable().getStdout().contains("tracking disable")); + assertTrue(cli().trackingEnable().getStdout().contains("tracking enable")); + assertTrue(cli().execute("future-command", "--x").getStdout() + .contains("future-command --x")); } } diff --git a/src/test/java/io/github/easy4j/comfy/mcp/ComfyMcpClientE2ETest.java b/src/test/java/io/github/easy4j/comfy/mcp/ComfyMcpClientE2ETest.java index 9102532..95a7ce3 100644 --- a/src/test/java/io/github/easy4j/comfy/mcp/ComfyMcpClientE2ETest.java +++ b/src/test/java/io/github/easy4j/comfy/mcp/ComfyMcpClientE2ETest.java @@ -1,17 +1,5 @@ /* - * Copyright (c) 2018-present, easy-4-java (https://github.com/easy-4-java). - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Copyright (c) 2018-present, easy-4-java. */ package io.github.easy4j.comfy.mcp; @@ -22,6 +10,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.file.Paths; +import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -30,59 +19,80 @@ import io.github.easy4j.comfy.ComfyException; -/** - * End-to-end tests running {@link ComfyMcpClient} against a fake MCP server - * process (python3, NDJSON JSON-RPC on stdio) — the same wire contract as - * {@code comfy-mcp}. - * - * @since 1.0.0 - */ class ComfyMcpClientE2ETest { - private static final String FAKE_SERVER = Paths.get("src", "test", "resources", "fake-mcp-server.py") + private static final String FAKE_SERVER = Paths + .get("src", "test", "resources", "fake-mcp-server.py") .toAbsolutePath().toString(); private static ComfyMcpConfig config() { ComfyMcpConfig config = new ComfyMcpConfig(); config.setLocalExecutable("python3"); config.setMcpArgs(new String[] {FAKE_SERVER}); - config.setConnectTimeoutMillis(10_000); - config.setReadTimeoutMillis(10_000); + config.setConnectTimeoutMillis(5_000); + config.setReadTimeoutMillis(5_000); + config.setShutdownTimeoutMillis(1_000); return config; } @Test - void shouldConnectAndInitialize() { + void shouldConnectListToolsAndRejectDuplicateConnect() { try (ComfyMcpClient client = new ComfyMcpClient(config())) { - String version = client.connect(); - - assertEquals("0.0.0-test", version); + assertEquals("0.0.0-test", client.connect()); assertEquals("FakeComfyMcp", client.getServerName()); + assertTrue(client.isConnected()); + List tools = client.listTools(); + assertEquals(3, tools.size()); + assertThrows(IllegalStateException.class, client::connect); } } @Test - void shouldListToolsAndCallThem() { + void shouldCallTypedWorkflowWrapper() { try (ComfyMcpClient client = new ComfyMcpClient(config())) { client.connect(); + ComfyMcpCallResult result = client.runWorkflow("wf.json", false, 12.5, false); + assertFalse(result.isError()); + assertTrue(result.getText().contains("\"workflow_path\": \"wf.json\"")); + assertTrue(result.getText().contains("\"wait\": false")); + } + } - List tools = client.listTools(); - assertEquals(2, tools.size()); - assertEquals("server_info", tools.get(0).getName()); - assertNotNull(tools.get(1).getInputSchema()); - - ComfyMcpCallResult info = client.callTool("server_info", null); - assertFalse(info.isError()); - assertEquals("comfyui up", info.getText()); - - Map args = new LinkedHashMap(); - args.put("workflow_path", "wf.json"); - ComfyMcpCallResult run = client.callTool("run_workflow", args); - assertFalse(run.isError()); - assertEquals("queued wf.json", run.getText()); - - ComfyMcpCallResult unknown = client.callTool("nope", null); - assertTrue(unknown.isError()); + @Test + void shouldPreserveNonTextContent() { + try (ComfyMcpClient client = new ComfyMcpClient(config())) { + client.connect(); + ComfyMcpCallResult result = client.callTool("mixed_content", null); + assertEquals("hello", result.getText()); + assertEquals(3, result.getContents().size()); + assertEquals("image", result.getContents().get(1).getType()); + assertEquals("image/png", result.getContents().get(1).getMimeType()); + assertEquals("aGVsbG8=", result.getContents().get(1).getData()); + assertEquals("file:///tmp/out.png", result.getContents().get(2).getUri()); + } + } + + @Test + void shouldDrainLargeStderrWithoutDeadlock() { + ComfyMcpConfig config = config(); + Map env = new LinkedHashMap(); + env.put("FAKE_MCP_STDERR_BYTES", "262144"); + config.setEnvironment(env); + try (ComfyMcpClient client = new ComfyMcpClient(config)) { + client.connect(); + assertEquals("comfyui up", client.callTool("server_info", null).getText()); + } + } + + @Test + void shouldRemoveTimedOutRpcFromPendingMap() throws Exception { + ComfyMcpConfig config = config(); + config.setReadTimeoutMillis(100); + try (ComfyMcpClient client = new ComfyMcpClient(config)) { + client.connect(); + assertThrows(ComfyException.class, () -> client.callTool("slow", null)); + Thread.sleep(50L); + assertEquals(0, client.pendingRequestCount()); } } @@ -92,8 +102,8 @@ void shouldRejectCallsBeforeConnectAndAfterClose() { assertThrows(IllegalStateException.class, () -> client.callToolAsync("server_info", null)); client.close(); assertTrue(client.isClosed()); + assertFalse(client.isConnected()); assertThrows(IllegalStateException.class, () -> client.callToolAsync("server_info", null)); - assertThrows(ComfyException.class, () -> client.listTools()); client.close(); } @@ -101,34 +111,32 @@ void shouldRejectCallsBeforeConnectAndAfterClose() { void shouldFailConnectWhenServerExitsPrematurely() { ComfyMcpConfig config = config(); config.setLocalExecutable("/bin/echo"); - ComfyMcpClient client = new ComfyMcpClient(config); - assertThrows(ComfyException.class, client::connect); - client.close(); + try (ComfyMcpClient client = new ComfyMcpClient(config)) { + assertThrows(ComfyException.class, client::connect); + } } @Test - void shouldTimeoutWhenServerNeverAnswers() { - ComfyMcpConfig config = new ComfyMcpConfig(); - // `sleep` produces no stdout: the initialize request is never answered. - config.setLocalExecutable("/bin/sleep"); - config.setMcpArgs(new String[] {"30"}); - config.setConnectTimeoutMillis(1_000); - ComfyMcpClient client = new ComfyMcpClient(config); - assertThrows(ComfyException.class, client::connect); - client.close(); + void shouldPassEnvironmentAndExerciseCurrentToolConveniences() { + ComfyMcpConfig config = config(); + Map env = new LinkedHashMap(); + env.put("COMFY_BIN", "/opt/venv/bin/comfy"); + config.setEnvironment(env); + try (ComfyMcpClient client = new ComfyMcpClient(config)) { + client.connect(); + assertNotNull(client.serverInfo()); + assertTrue(client.fetchOutputs("p1", "/tmp/out", true, false).getText().contains("p1")); + assertTrue(client.searchModels("wan", "loras").getText().contains("wan")); + assertTrue(client.launchComfyUi(Arrays.asList("--port", "8188"), false) + .getText().contains("8188")); + } } @Test - void shouldPassEnvironmentToServer() { - ComfyMcpConfig config = config(); - Map args = new LinkedHashMap(); - // COMFY_BIN 场景由 env 注入承载——这里以 env 透传间接验证(fake server 不读 env, - // 但 spawn 不因额外 env 失败即视为通过;真实验证在 executor 测试覆盖)。 - config.setEnvironment(new LinkedHashMap()); - config.getEnvironment().put("COMFY_BIN", "/opt/venv/bin/comfy"); - try (ComfyMcpClient client = new ComfyMcpClient(config)) { + void businessLevelMcpErrorsShouldRemainResults() { + try (ComfyMcpClient client = new ComfyMcpClient(config())) { client.connect(); - assertFalse(client.listTools().isEmpty()); + assertTrue(client.callTool("nope", null).isError()); } } } diff --git a/src/test/java/io/github/easy4j/comfy/mcp/ComfyMcpConfigTest.java b/src/test/java/io/github/easy4j/comfy/mcp/ComfyMcpConfigTest.java index c98e2a2..026f711 100644 --- a/src/test/java/io/github/easy4j/comfy/mcp/ComfyMcpConfigTest.java +++ b/src/test/java/io/github/easy4j/comfy/mcp/ComfyMcpConfigTest.java @@ -1,17 +1,5 @@ /* - * Copyright (c) 2018-present, easy-4-java (https://github.com/easy-4-java). - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Copyright (c) 2018-present, easy-4-java. */ package io.github.easy4j.comfy.mcp; @@ -20,35 +8,28 @@ import org.junit.jupiter.api.Test; -/** Unit tests for {@link ComfyMcpConfig}. @since 1.0.0 */ class ComfyMcpConfigTest { @Test - void shouldExposeSensibleDefaults() { + void shouldExposeBoundedDefaults() { ComfyMcpConfig config = new ComfyMcpConfig(); assertEquals("comfy-mcp", config.getLocalExecutable()); assertEquals("2024-11-05", config.getProtocolVersion()); - assertEquals("comfy-java-sdk", config.getClientName()); assertEquals(10_000, config.getConnectTimeoutMillis()); assertEquals(900_000, config.getReadTimeoutMillis()); - assertEquals(1_048_576, config.getMaxFrameChars()); - assertEquals(1_048_576, config.getMaxContentChars()); + assertEquals(2_000, config.getShutdownTimeoutMillis()); + assertEquals(16 * 1024 * 1024, config.getMaxFrameChars()); + assertEquals(4 * 1024 * 1024, config.getMaxContentChars()); } @Test - void shouldAcceptExtraArgsAndEnvironment() { + void shouldRejectInvalidTimeouts() { ComfyMcpConfig config = new ComfyMcpConfig(); - config.setMcpArgs(new String[] {"--port", "8188"}); - config.setEnvironment(java.util.Collections.singletonMap("COMFY_BIN", "/opt/venv/bin/comfy")); - config.validate(); - assertEquals(2, config.getMcpArgs().length); - assertEquals("/opt/venv/bin/comfy", config.getEnvironment().get("COMFY_BIN")); - } + config.setConnectTimeoutMillis(0); + assertThrows(IllegalStateException.class, config::validate); - @Test - void shouldRejectNullExecutable() { - ComfyMcpConfig config = new ComfyMcpConfig(); - config.setLocalExecutable(null); - assertThrows(NullPointerException.class, config::validate); + config = new ComfyMcpConfig(); + config.setReadTimeoutMillis(0); + assertThrows(IllegalStateException.class, config::validate); } } diff --git a/src/test/resources/comfy-envelope.sh b/src/test/resources/comfy-envelope.sh new file mode 100755 index 0000000..fbae5ee --- /dev/null +++ b/src/test/resources/comfy-envelope.sh @@ -0,0 +1,2 @@ +#!/bin/sh +printf '%s\n' '{"ok":true,"command":"env","version":"1.15.0","where":"local","data":{"running":true},"error":null}' diff --git a/src/test/resources/comfy-slow.sh b/src/test/resources/comfy-slow.sh new file mode 100755 index 0000000..46dca8c --- /dev/null +++ b/src/test/resources/comfy-slow.sh @@ -0,0 +1,2 @@ +#!/bin/sh +sleep 5 diff --git a/src/test/resources/fake-mcp-server.py b/src/test/resources/fake-mcp-server.py index 2cbffad..88da22e 100755 --- a/src/test/resources/fake-mcp-server.py +++ b/src/test/resources/fake-mcp-server.py @@ -1,70 +1,68 @@ #!/usr/bin/env python3 -"""Fake comfy-mcp server for end-to-end tests. - -Speaks newline-delimited JSON-RPC on stdio exactly like an MCP stdio server: -answers `initialize` (serverInfo) and `notifications/initialized` silently, -`tools/list` with a small catalog, `tools/call` for `server_info` (fast) and -`run_workflow` (returns text content + isError=False). Unknown requests get -a method-not-found error. -""" +"""Fake comfy-mcp server used by Java transport/typed-wrapper tests.""" import json +import os import sys +import time +flood = int(os.environ.get("FAKE_MCP_STDERR_BYTES", "0") or "0") +if flood > 0: + sys.stderr.write("x" * flood) + sys.stderr.flush() def send(payload): sys.stdout.write(json.dumps(payload) + "\n") sys.stdout.flush() - def reply(req_id, result): send({"jsonrpc": "2.0", "id": req_id, "result": result}) +for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + frame = json.loads(line) + except ValueError: + continue + method = frame.get("method", "") + req_id = frame.get("id") + params = frame.get("params") or {} -def main(): - for line in sys.stdin: - line = line.strip() - if not line: - continue - try: - frame = json.loads(line) - except ValueError: - continue - method = frame.get("method", "") - req_id = frame.get("id") - params = frame.get("params") or {} - - if method == "initialize": - reply(req_id, { - "protocolVersion": params.get("protocolVersion", "2024-11-05"), - "capabilities": {"tools": {}}, - "serverInfo": {"name": "FakeComfyMcp", "version": "0.0.0-test"}, - }) - elif method == "tools/list": - reply(req_id, {"tools": [ - {"name": "server_info", "description": "verify ComfyUI is up", - "inputSchema": {"type": "object", "properties": {}}}, - {"name": "run_workflow", "description": "run a workflow file", - "inputSchema": {"type": "object", - "properties": {"workflow_path": {"type": "string"}, - "wait": {"type": "boolean"}}}}, - ]}) - elif method == "tools/call": - name = params.get("name", "") - if name == "server_info": - reply(req_id, {"content": [{"type": "text", "text": "comfyui up"}], "isError": False}) - elif name == "run_workflow": - path = (params.get("arguments") or {}).get("workflow_path", "") - reply(req_id, {"content": [ - {"type": "text", "text": "queued "}, - {"type": "text", "text": path}, - ], "isError": False}) - else: - reply(req_id, {"content": [{"type": "text", "text": "unknown tool: " + name}], - "isError": True}) - elif req_id is not None: - send({"jsonrpc": "2.0", "id": req_id, - "error": {"code": -32601, "message": "method not found: " + method}}) - - -if __name__ == "__main__": - main() + if method == "initialize": + reply(req_id, { + "protocolVersion": params.get("protocolVersion", "2024-11-05"), + "capabilities": {"tools": {}}, + "serverInfo": {"name": "FakeComfyMcp", "version": "0.0.0-test"}, + }) + elif method == "tools/list": + reply(req_id, {"tools": [ + {"name": "server_info", "description": "verify ComfyUI is up", + "inputSchema": {"type": "object", "properties": {}}}, + {"name": "run_workflow", "description": "run a workflow file", + "inputSchema": {"type": "object", "properties": {"workflow_path": {"type": "string"}}}}, + {"name": "mixed_content", "description": "mixed MCP content", + "inputSchema": {"type": "object", "properties": {}}}, + ]}) + elif method == "tools/call": + name = params.get("name", "") + args = params.get("arguments") or {} + if name == "server_info": + reply(req_id, {"content": [{"type": "text", "text": "comfyui up"}], "isError": False}) + elif name == "slow": + time.sleep(2.0) + reply(req_id, {"content": [{"type": "text", "text": "late"}], "isError": False}) + elif name == "mixed_content": + reply(req_id, {"content": [ + {"type": "text", "text": "hello"}, + {"type": "image", "mimeType": "image/png", "data": "aGVsbG8="}, + {"type": "resource_link", "uri": "file:///tmp/out.png"}, + ], "isError": False}) + elif name == "nope": + reply(req_id, {"content": [{"type": "text", "text": "unknown tool"}], "isError": True}) + else: + reply(req_id, {"content": [{"type": "text", "text": json.dumps(args, sort_keys=True)}], + "isError": False}) + elif req_id is not None: + send({"jsonrpc": "2.0", "id": req_id, + "error": {"code": -32601, "message": "method not found: " + method}}) From ccfa9b7ff078af3404e37a83dfece8bba895403b Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Sun, 20 Sep 2026 21:18:15 +0800 Subject: [PATCH 2/3] fix(security): upgrade Jackson 3.2.2 --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 3909018..748a474 100644 --- a/pom.xml +++ b/pom.xml @@ -41,8 +41,8 @@ UTF-8 1.6.0 - 3.2.1 - 3.2.1 + 3.2.2 + 3.2.2 6.1.0 5.11.4 1.18.46 From 34ba39196bd56a899ee4ea3c4a8da3da8e82ba2c Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Sun, 20 Sep 2026 21:19:26 +0800 Subject: [PATCH 3/3] test: isolate probe timeout and finish MCP signature parity --- .../io/github/easy4j/comfy/mcp/ComfyMcpClient.java | 14 ++++++++++++-- src/test/resources/comfy-slow.sh | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/github/easy4j/comfy/mcp/ComfyMcpClient.java b/src/main/java/io/github/easy4j/comfy/mcp/ComfyMcpClient.java index b2117fa..da38c00 100644 --- a/src/main/java/io/github/easy4j/comfy/mcp/ComfyMcpClient.java +++ b/src/main/java/io/github/easy4j/comfy/mcp/ComfyMcpClient.java @@ -258,7 +258,12 @@ public ComfyMcpCallResult watchJob(String promptId, double timeoutSeconds) { public ComfyMcpCallResult getQueue() { return job("queue", null, null); } public ComfyMcpCallResult systemStats() { return callTool("system_stats", null); } - public ComfyMcpCallResult freeMemory() { return callTool("free_memory", null); } + public ComfyMcpCallResult freeMemory() { return freeMemory(true, null); } + public ComfyMcpCallResult freeMemory(boolean unloadModels, Boolean freeMemory) { + Map args = params("unload_models", Boolean.valueOf(unloadModels)); + putIfNotNull(args, "free_memory", freeMemory); + return callTool("free_memory", args); + } public ComfyMcpCallResult fetchOutputs(String promptId, String outDir, boolean urlOnly, boolean inlineImages) { @@ -309,7 +314,12 @@ public ComfyMcpCallResult getLogs(int tail, Integer port) { return callTool("get_logs", args); } - public ComfyMcpCallResult discover() { return callTool("discover", null); } + public ComfyMcpCallResult discover() { return discover(true, ""); } + public ComfyMcpCallResult discover(boolean schemasOnly, String command) { + return callTool("discover", params( + "schemas_only", Boolean.valueOf(schemasOnly), + "command", nullToEmpty(command))); + } public ComfyMcpCallResult which() { return callTool("which", null); } public ComfyMcpCallResult project(String action) { diff --git a/src/test/resources/comfy-slow.sh b/src/test/resources/comfy-slow.sh index 46dca8c..9677378 100755 --- a/src/test/resources/comfy-slow.sh +++ b/src/test/resources/comfy-slow.sh @@ -1,2 +1,2 @@ #!/bin/sh -sleep 5 +exec sleep 5