@fre4x/comfyui 1.0.61 → 1.0.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +48 -30
  2. package/dist/index.js +562 -163
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -4,18 +4,12 @@ An MCP server to interact with ComfyUI remotely. Probe server state, inspect nod
4
4
 
5
5
  ## Tools
6
6
 
7
- - `comfyui_get_system_info`: Get server hardware and software status.
8
- - `comfyui_list_object_info`: List available nodes and their schema (inputs/outputs).
9
- - `comfyui_execute_workflow`: Submit a workflow in API JSON format.
10
- - `comfyui_list_workflows`: List stored reusable workflows.
11
- - `comfyui_search_workflows`: Search stored workflows by name, tag, or description.
12
- - `comfyui_get_workflow`: Inspect a stored workflow and its editable inputs.
13
- - `comfyui_save_workflow`: Save a workflow for later reuse.
14
- - `comfyui_run_workflow`: Run a stored workflow with optional overrides.
15
- - `comfyui_get_history`: Retrieve job history and outputs.
7
+ - `comfyui_inspect_node`: List available nodes or inspect one node schema.
8
+ - `comfyui_discover_workflows`: Scan known workflow directories for stored JSON files.
9
+ - `comfyui_get_workflow`: Normalize a stored workflow and list its editable inputs.
10
+ - `comfyui_save_workflow`: Save a workflow into local `./workflows` with optional edits.
11
+ - `comfyui_workflow_run`: Run a stored workflow with optional overrides.
16
12
  - `comfyui_wait_for_workflow`: Wait for workflow completion with a timeout.
17
- - `comfyui_get_queue`: Check current server queue.
18
- - `comfyui_get_view_url`: Get a direct URL for a generated file.
19
13
 
20
14
  **Do not assume `structuredContent` is what the model actually sees.** Many MCP clients
21
15
  surface `content.text` to the model first and treat `structuredContent` as secondary
@@ -28,7 +22,6 @@ IDs, and recovery hints.
28
22
  ### Environment Variables
29
23
 
30
24
  - `COMFYUI_SERVER_URL`: The URL of your ComfyUI server (default: `http://localhost:8188`).
31
- - `COMFYUI_WORKFLOWS_DIR`: Directory used for saved workflow registry files (default: `~/.fre4x-comfyui/workflows`).
32
25
  - `MOCK`: Set to `true` to use mock data instead of a real server.
33
26
 
34
27
  ### Claude Desktop Configuration
@@ -57,35 +50,60 @@ MOCK=true npx @fre4x/comfyui
57
50
 
58
51
  ## Workflow API Format
59
52
 
60
- Note: To use `comfyui_execute_workflow`, you must use the **API Format** JSON. In ComfyUI, enable "Developer Mode" in settings, then click "Save (API Format)". This format differs from the standard workflow JSON.
53
+ `comfyui_workflow_run` and `comfyui_save_workflow` both normalize workflows into
54
+ ComfyUI **API Format** before execution or persistence. In ComfyUI, enable
55
+ "Developer Mode" in settings, then click "Save (API Format)" if you want the
56
+ raw graph JSON. Standard Web UI workflow JSON is also accepted and normalized
57
+ automatically.
61
58
 
62
59
  If you want a single call that submits and waits, pass `await: true` with a
63
- `timeout` value in seconds to `comfyui_execute_workflow`. For lower-level
64
- control, use `comfyui_wait_for_workflow` with `prompt_id`,
65
- `timeout_seconds`, and `poll_interval_ms`.
60
+ `timeout` value in seconds to `comfyui_workflow_run`. For lower-level control,
61
+ use `comfyui_wait_for_workflow` with `prompt_id` and `timeout`.
66
62
 
67
63
  ## Stored Workflow Reuse
68
64
 
69
- The workflow registry tools let you avoid re-sending large API JSON blobs.
65
+ The stored-workflow tools let you avoid re-sending large API JSON blobs and give
66
+ agents a stable local edit loop.
70
67
 
71
- 1. Save a workflow with `comfyui_save_workflow`
72
- 2. Inspect it with `comfyui_get_workflow`
73
- 3. Reuse it with `comfyui_run_workflow`
68
+ 1. Save a workflow with `comfyui_save_workflow` into local `./workflows`
69
+ 2. Inspect editable paths with `comfyui_get_workflow`
70
+ 3. Persist edits with `comfyui_save_workflow` overrides
71
+ 4. Reuse it with `comfyui_workflow_run`
74
72
 
75
- `comfyui_run_workflow` accepts structured overrides in this form:
73
+ `comfyui_workflow_run` and `comfyui_save_workflow` both accept dot-notation
74
+ overrides in this form:
76
75
 
77
76
  ```json
78
77
  {
79
78
  "workflow_id": "pony-portrait-v1",
80
- "overrides": [
81
- {
82
- "node_id": "2",
83
- "input_name": "seed",
84
- "value": 67890
85
- }
86
- ],
79
+ "overrides": {
80
+ "2.inputs.seed": 67890,
81
+ "6.inputs.text": "cinematic dragon portrait"
82
+ },
87
83
  "await": true,
88
- "timeout": 90,
89
- "require_images": true
84
+ "timeout": 90
85
+ }
86
+ ```
87
+
88
+ To inspect editable inputs and semantic hints for agent editing:
89
+
90
+ ```json
91
+ {
92
+ "workflow_id": "pony-portrait-v1",
93
+ "include_prompt": false
94
+ }
95
+ ```
96
+
97
+ To save a new or edited workflow locally:
98
+
99
+ ```json
100
+ {
101
+ "workflow_file_path": "pony-portrait-v1.json",
102
+ "overrides": {
103
+ "6.inputs.text": "studio lighting, ultra detailed",
104
+ "7.inputs.filename_prefix": "pony-portrait-agent"
105
+ },
106
+ "output_file_name": "pony-portrait-agent.json",
107
+ "overwrite": true
90
108
  }
91
109
  ```
package/dist/index.js CHANGED
@@ -30630,9 +30630,16 @@ function convertWebUIToAPI(webUIJson, objectInfo) {
30630
30630
  const prompt = {};
30631
30631
  const links = webUIJson.links || [];
30632
30632
  const nodes = webUIJson.nodes || [];
30633
+ const serverNodeIds = /* @__PURE__ */ new Set();
30634
+ for (const node of nodes) {
30635
+ if (!node.id || !node.type || !objectInfo[node.type]) {
30636
+ continue;
30637
+ }
30638
+ serverNodeIds.add(String(node.id));
30639
+ }
30633
30640
  const linkMap = /* @__PURE__ */ new Map();
30634
30641
  for (const link of links) {
30635
- if (isLinkData(link)) {
30642
+ if (isLinkData(link) && serverNodeIds.has(String(link[3]))) {
30636
30643
  linkMap.set(link[0], link);
30637
30644
  }
30638
30645
  }
@@ -30650,7 +30657,7 @@ function convertWebUIToAPI(webUIJson, objectInfo) {
30650
30657
  for (const input of node.inputs) {
30651
30658
  if (input.name && input.link !== null && input.link !== void 0) {
30652
30659
  const linkData = linkMap.get(input.link);
30653
- if (Array.isArray(linkData) && linkData.length >= 6) {
30660
+ if (Array.isArray(linkData) && linkData.length >= 6 && String(linkData[3]) === nodeIdStr) {
30654
30661
  const originNodeId = String(linkData[1]);
30655
30662
  const originSlot = linkData[2];
30656
30663
  promptNode.inputs[input.name] = [
@@ -30723,6 +30730,14 @@ var ComfyApiError = class extends Error {
30723
30730
  this.name = "ComfyApiError";
30724
30731
  }
30725
30732
  };
30733
+ var WorkflowInputError = class extends Error {
30734
+ constructor(message, field, kind = "validation") {
30735
+ super(message);
30736
+ this.field = field;
30737
+ this.kind = kind;
30738
+ this.name = "WorkflowInputError";
30739
+ }
30740
+ };
30726
30741
  function isRecord(value) {
30727
30742
  return typeof value === "object" && value !== null;
30728
30743
  }
@@ -30832,7 +30847,7 @@ async function fetchComfyJson(apiPath, options) {
30832
30847
  }
30833
30848
  return body;
30834
30849
  }
30835
- async function fetchImageBase64(filename, type, subfolder) {
30850
+ async function fetchImageAsset(filename, type, subfolder) {
30836
30851
  const params = new URLSearchParams({ filename, type });
30837
30852
  if (subfolder) params.set("subfolder", subfolder);
30838
30853
  const url2 = new URL(
@@ -30845,10 +30860,47 @@ async function fetchImageBase64(filename, type, subfolder) {
30845
30860
  const arrayBuffer = await response.arrayBuffer();
30846
30861
  const mimeType = response.headers.get("content-type") || "image/png";
30847
30862
  return {
30848
- data: Buffer.from(arrayBuffer).toString("base64"),
30863
+ buffer: Buffer.from(arrayBuffer),
30849
30864
  mimeType
30850
30865
  };
30851
30866
  }
30867
+ function createMockImageAsset() {
30868
+ return {
30869
+ buffer: Buffer.from("mock-image-data"),
30870
+ mimeType: "image/png"
30871
+ };
30872
+ }
30873
+ function getSessionWorkspaceDir() {
30874
+ return process.env.MCP_WORKSPACE_DIR;
30875
+ }
30876
+ function getSafePathSegments(subfolder) {
30877
+ if (!subfolder) return [];
30878
+ return subfolder.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".." && s !== ".");
30879
+ }
30880
+ async function saveToWorkspace(promptId, image, asset) {
30881
+ const workspaceDir = getSessionWorkspaceDir();
30882
+ if (!workspaceDir) {
30883
+ return void 0;
30884
+ }
30885
+ const safeFileName = path.basename(image.filename);
30886
+ const safeSubfolderSegments = getSafePathSegments(image.subfolder);
30887
+ const relativePath = path.join(
30888
+ "comfyui",
30889
+ promptId,
30890
+ ...safeSubfolderSegments,
30891
+ safeFileName
30892
+ );
30893
+ const workspacePath = path.join(workspaceDir, relativePath);
30894
+ await fs.mkdir(path.dirname(workspacePath), { recursive: true });
30895
+ await fs.writeFile(workspacePath, asset.buffer);
30896
+ return {
30897
+ workspace_path: workspacePath,
30898
+ relative_path: relativePath,
30899
+ filename: safeFileName,
30900
+ mime_type: asset.mimeType,
30901
+ subfolder: image.subfolder
30902
+ };
30903
+ }
30852
30904
  function handleComfyError(error48) {
30853
30905
  if (error48 instanceof ZodError) {
30854
30906
  return createValidationError(
@@ -30856,6 +30908,9 @@ function handleComfyError(error48) {
30856
30908
  error48.issues[0]?.message || "Invalid input."
30857
30909
  );
30858
30910
  }
30911
+ if (error48 instanceof WorkflowInputError) {
30912
+ return error48.kind === "not_found" ? createNotFoundError(error48.message) : createValidationError(error48.field, error48.message);
30913
+ }
30859
30914
  if (error48 instanceof ComfyApiError) {
30860
30915
  const detailSummary = summarizeComfyErrorDetails(error48.details);
30861
30916
  return {
@@ -30870,11 +30925,27 @@ ${detailSummary}` : error48.message
30870
30925
  isError: true
30871
30926
  };
30872
30927
  }
30928
+ const errorMessage = error48 instanceof Error ? error48.message : String(error48);
30929
+ if (errorMessage.includes("Timeout waiting for workflow")) {
30930
+ return {
30931
+ content: [
30932
+ {
30933
+ type: "text",
30934
+ text: `${errorMessage}.
30935
+
30936
+ The workflow is likely still running on the server. You can re-attach to it and wait for completion by calling 'comfyui_wait_for_workflow' again with the same prompt_id.`
30937
+ }
30938
+ ],
30939
+ isError: true
30940
+ };
30941
+ }
30873
30942
  return createInternalError(error48);
30874
30943
  }
30875
- async function validateWorkflowPath(targetPath) {
30876
- const resolvedPath = path.resolve(targetPath);
30877
- const allowedRoots = [
30944
+ function getWorkflowDirectories(extraPath) {
30945
+ const directories = [
30946
+ process.cwd(),
30947
+ path.join(process.cwd(), "workflows"),
30948
+ path.join(homedir(), ".fre4x-comfyui", "workflows"),
30878
30949
  path.join(
30879
30950
  homedir(),
30880
30951
  "comfy",
@@ -30882,24 +30953,291 @@ async function validateWorkflowPath(targetPath) {
30882
30953
  "user",
30883
30954
  "default",
30884
30955
  "workflows"
30885
- ),
30886
- path.join(homedir(), ".fre4x-comfyui", "workflows"),
30887
- path.join(process.cwd(), "workflows")
30956
+ )
30888
30957
  ];
30889
- const isAllowed = allowedRoots.some(
30890
- (root) => resolvedPath.startsWith(path.resolve(root))
30958
+ if (extraPath) {
30959
+ directories.push(extraPath);
30960
+ }
30961
+ return directories;
30962
+ }
30963
+ function getWorkflowNameCandidates(name) {
30964
+ const trimmed = name.trim();
30965
+ if (trimmed.length === 0) {
30966
+ return [];
30967
+ }
30968
+ if (trimmed.endsWith(".json")) {
30969
+ return [trimmed];
30970
+ }
30971
+ return [trimmed, `${trimmed}.json`];
30972
+ }
30973
+ function normalizeWorkflowFileName(fileName) {
30974
+ const trimmed = fileName.trim();
30975
+ if (trimmed.length === 0) {
30976
+ throw new WorkflowInputError(
30977
+ "Filename cannot be empty.",
30978
+ "output_file_name"
30979
+ );
30980
+ }
30981
+ return trimmed.endsWith(".json") ? trimmed : `${trimmed}.json`;
30982
+ }
30983
+ function isConnectionValue(value) {
30984
+ return Array.isArray(value) && value.length >= 2 && (typeof value[0] === "string" || typeof value[0] === "number") && typeof value[1] === "number";
30985
+ }
30986
+ async function getObjectInfo() {
30987
+ return IS_MOCK ? MOCK_FIXTURES.object_info : await fetchComfyJson(
30988
+ "/object_info"
30891
30989
  );
30892
- if (!isAllowed) {
30893
- throw new Error(
30894
- `Access denied: ${targetPath} is outside allowed workflow directories.`
30990
+ }
30991
+ async function resolveWorkflowReference(args) {
30992
+ if (args.workflow_file_path) {
30993
+ const targetPath = args.workflow_file_path;
30994
+ try {
30995
+ const resolvedPath = path.resolve(targetPath);
30996
+ const content = await fs.readFile(resolvedPath, "utf8");
30997
+ return {
30998
+ targetPath: resolvedPath,
30999
+ content
31000
+ };
31001
+ } catch {
31002
+ if (path.basename(targetPath) === targetPath) {
31003
+ const candidates = getWorkflowNameCandidates(targetPath);
31004
+ for (const dir of getWorkflowDirectories()) {
31005
+ for (const candidate of candidates) {
31006
+ const candidatePath = path.join(dir, candidate);
31007
+ try {
31008
+ const content = await fs.readFile(
31009
+ candidatePath,
31010
+ "utf8"
31011
+ );
31012
+ return {
31013
+ targetPath: candidatePath,
31014
+ content
31015
+ };
31016
+ } catch {
31017
+ }
31018
+ }
31019
+ }
31020
+ }
31021
+ throw new WorkflowInputError(
31022
+ `Workflow file not found: ${targetPath}`,
31023
+ "workflow_file_path",
31024
+ "not_found"
31025
+ );
31026
+ }
31027
+ }
31028
+ if (args.workflow_id) {
31029
+ const requestedId = args.workflow_id.trim();
31030
+ if (requestedId.length === 0) {
31031
+ throw new WorkflowInputError(
31032
+ "Workflow ID cannot be empty.",
31033
+ "workflow_id"
31034
+ );
31035
+ }
31036
+ for (const dir of getWorkflowDirectories()) {
31037
+ try {
31038
+ const entries = await fs.readdir(dir, { withFileTypes: true });
31039
+ for (const entry of entries) {
31040
+ if (!entry.isFile() || !entry.name.endsWith(".json")) {
31041
+ continue;
31042
+ }
31043
+ const fullPath = path.join(dir, entry.name);
31044
+ const defaultId = getWorkflowId(fullPath);
31045
+ if (requestedId === defaultId || requestedId === entry.name.replace(/\.json$/, "")) {
31046
+ return {
31047
+ targetPath: fullPath,
31048
+ content: await fs.readFile(fullPath, "utf8")
31049
+ };
31050
+ }
31051
+ }
31052
+ } catch {
31053
+ }
31054
+ }
31055
+ throw new WorkflowInputError(
31056
+ `Workflow ID not found: ${args.workflow_id}`,
31057
+ "workflow_id",
31058
+ "not_found"
30895
31059
  );
30896
31060
  }
31061
+ throw new WorkflowInputError(
31062
+ "Must provide either workflow_file_path or workflow_id.",
31063
+ "workflow_id"
31064
+ );
31065
+ }
31066
+ async function normalizeWorkflowPrompt(workflow) {
31067
+ if (!isRecord(workflow)) {
31068
+ throw new WorkflowInputError(
31069
+ "Workflow must be a JSON object.",
31070
+ "workflow"
31071
+ );
31072
+ }
31073
+ let sourceFormat = "api";
31074
+ let normalizedWorkflow = workflow;
31075
+ if (isWebUIFormat(normalizedWorkflow)) {
31076
+ sourceFormat = "webui";
31077
+ normalizedWorkflow = convertWebUIToAPI(
31078
+ normalizedWorkflow,
31079
+ await getObjectInfo()
31080
+ );
31081
+ }
31082
+ const prompt = isRecord(normalizedWorkflow.prompt) && normalizedWorkflow.prompt ? normalizedWorkflow.prompt : normalizedWorkflow;
31083
+ if (!isRecord(prompt)) {
31084
+ throw new WorkflowInputError(
31085
+ "Workflow prompt must be a JSON object.",
31086
+ "workflow"
31087
+ );
31088
+ }
31089
+ return {
31090
+ prompt,
31091
+ sourceFormat
31092
+ };
31093
+ }
31094
+ async function loadWorkflow(args) {
31095
+ const { targetPath, content } = await resolveWorkflowReference(args);
31096
+ let parsed;
31097
+ try {
31098
+ parsed = JSON.parse(content);
31099
+ } catch (error48) {
31100
+ throw new WorkflowInputError(
31101
+ `Invalid JSON in ${targetPath}: ${error48 instanceof Error ? error48.message : String(error48)}`,
31102
+ args.workflow_file_path ? "workflow_file_path" : "workflow_id"
31103
+ );
31104
+ }
31105
+ const { prompt, sourceFormat } = await normalizeWorkflowPrompt(parsed);
31106
+ return {
31107
+ targetPath,
31108
+ prompt,
31109
+ sourceFormat
31110
+ };
31111
+ }
31112
+ function inferEditableRole(nodeId, classType, inputName, consumerMap) {
31113
+ if (classType === "CLIPTextEncode" && inputName === "text") {
31114
+ const consumers = consumerMap.get(nodeId) ?? [];
31115
+ if (consumers.some((consumer) => consumer.inputName === "positive")) {
31116
+ return "positive_prompt";
31117
+ }
31118
+ if (consumers.some((consumer) => consumer.inputName === "negative")) {
31119
+ return "negative_prompt";
31120
+ }
31121
+ return "prompt_text";
31122
+ }
31123
+ switch (inputName) {
31124
+ case "seed":
31125
+ return "seed";
31126
+ case "steps":
31127
+ return "steps";
31128
+ case "cfg":
31129
+ return "cfg";
31130
+ case "sampler_name":
31131
+ return "sampler";
31132
+ case "scheduler":
31133
+ return "scheduler";
31134
+ case "denoise":
31135
+ return "denoise";
31136
+ case "width":
31137
+ return "width";
31138
+ case "height":
31139
+ return "height";
31140
+ case "batch_size":
31141
+ return "batch_size";
31142
+ case "ckpt_name":
31143
+ return "checkpoint";
31144
+ case "filename_prefix":
31145
+ return "filename_prefix";
31146
+ case "text":
31147
+ return "text";
31148
+ default:
31149
+ return void 0;
31150
+ }
31151
+ }
31152
+ function formatValuePreview(value) {
31153
+ if (typeof value === "string") {
31154
+ return value.length > 100 ? `${value.slice(0, 97)}...` : value;
31155
+ }
30897
31156
  try {
30898
- await fs.access(resolvedPath);
30899
- return resolvedPath;
31157
+ const serialized = JSON.stringify(value);
31158
+ if (!serialized) {
31159
+ return String(value);
31160
+ }
31161
+ return serialized.length > 100 ? `${serialized.slice(0, 97)}...` : serialized;
30900
31162
  } catch {
30901
- throw new Error(`Workflow file not found: ${targetPath}`);
31163
+ return String(value);
31164
+ }
31165
+ }
31166
+ function analyzeEditableInputs(prompt) {
31167
+ const consumerMap = /* @__PURE__ */ new Map();
31168
+ for (const [nodeId, rawNode] of Object.entries(prompt)) {
31169
+ if (!isRecord(rawNode) || !isRecord(rawNode.inputs)) {
31170
+ continue;
31171
+ }
31172
+ const classType = typeof rawNode.class_type === "string" ? rawNode.class_type : "";
31173
+ if (!classType || ["Note", "MarkdownNote", "PrimitiveNode"].includes(classType) || nodeId === "meta") {
31174
+ continue;
31175
+ }
31176
+ for (const [inputName, value] of Object.entries(rawNode.inputs)) {
31177
+ if (!isConnectionValue(value)) {
31178
+ continue;
31179
+ }
31180
+ const sourceNodeId = String(value[0]);
31181
+ const consumers = consumerMap.get(sourceNodeId) ?? [];
31182
+ consumers.push({
31183
+ inputName,
31184
+ classType,
31185
+ nodeId
31186
+ });
31187
+ consumerMap.set(sourceNodeId, consumers);
31188
+ }
31189
+ }
31190
+ const editableInputs = [];
31191
+ for (const [nodeId, rawNode] of Object.entries(prompt)) {
31192
+ if (!isRecord(rawNode) || !isRecord(rawNode.inputs)) {
31193
+ continue;
31194
+ }
31195
+ const classType = typeof rawNode.class_type === "string" ? rawNode.class_type : "";
31196
+ if (!classType || ["Note", "MarkdownNote", "PrimitiveNode"].includes(classType) || nodeId === "meta") {
31197
+ continue;
31198
+ }
31199
+ for (const [inputName, value] of Object.entries(rawNode.inputs)) {
31200
+ if (isConnectionValue(value)) {
31201
+ continue;
31202
+ }
31203
+ editableInputs.push({
31204
+ path: `${nodeId}.inputs.${inputName}`,
31205
+ node_id: nodeId,
31206
+ class_type: classType,
31207
+ input_name: inputName,
31208
+ value,
31209
+ value_preview: formatValuePreview(value),
31210
+ role: inferEditableRole(
31211
+ nodeId,
31212
+ classType,
31213
+ inputName,
31214
+ consumerMap
31215
+ )
31216
+ });
31217
+ }
31218
+ }
31219
+ return editableInputs.sort(
31220
+ (left, right) => left.path.localeCompare(right.path, void 0, { numeric: true })
31221
+ );
31222
+ }
31223
+ function formatEditableInputsText(editableInputs) {
31224
+ if (editableInputs.length === 0) {
31225
+ return "No literal editable inputs were detected.";
31226
+ }
31227
+ return formatListItems(editableInputs, (entry) => {
31228
+ const role = entry.role ? ` [${entry.role}]` : "";
31229
+ const classType = entry.class_type ? ` (${entry.class_type})` : "";
31230
+ return `${entry.path}${role}${classType}: ${entry.value_preview}`;
31231
+ });
31232
+ }
31233
+ function deriveSavedWorkflowFileName(sourcePath, requestedName) {
31234
+ if (requestedName) {
31235
+ return normalizeWorkflowFileName(requestedName);
31236
+ }
31237
+ if (sourcePath) {
31238
+ return normalizeWorkflowFileName(path.basename(sourcePath));
30902
31239
  }
31240
+ return `workflow-${Date.now()}.json`;
30903
31241
  }
30904
31242
  function applyOverrides(prompt, overrides) {
30905
31243
  for (const [key, value] of Object.entries(overrides)) {
@@ -30908,7 +31246,10 @@ function applyOverrides(prompt, overrides) {
30908
31246
  for (let i = 0; i < parts.length - 1; i++) {
30909
31247
  const part = parts[i];
30910
31248
  if (typeof current[part] !== "object" || current[part] === null) {
30911
- current[part] = {};
31249
+ throw new WorkflowInputError(
31250
+ `Invalid override path: '${key}'. The segment '${part}' does not exist or is not an object. Please check the editable inputs using 'comfyui_get_workflow' to find the correct path.`,
31251
+ "overrides"
31252
+ );
30912
31253
  }
30913
31254
  current = current[part];
30914
31255
  }
@@ -30937,6 +31278,20 @@ var WorkflowRunSchema = z3.object({
30937
31278
  await: z3.boolean().default(true).describe("Wait for completion and return native images."),
30938
31279
  timeout: z3.number().int().min(1).default(120).describe("Wait timeout in seconds.")
30939
31280
  });
31281
+ var GetWorkflowSchema = z3.object({
31282
+ workflow_file_path: z3.string().optional().describe("Workflow path or filename to inspect."),
31283
+ workflow_id: z3.string().optional().describe("Workflow identifier returned by discovery."),
31284
+ include_prompt: z3.boolean().default(false).describe("Include normalized API JSON in structuredContent.")
31285
+ });
31286
+ var SaveWorkflowSchema = z3.object({
31287
+ workflow: z3.unknown().optional().describe("Workflow JSON object to save locally."),
31288
+ workflow_file_path: z3.string().optional().describe("Existing workflow path or filename to save."),
31289
+ workflow_id: z3.string().optional().describe("Existing workflow identifier to save."),
31290
+ overrides: z3.record(z3.string(), z3.unknown()).optional().describe("Dot-notation overrides to persist before saving."),
31291
+ output_file_name: z3.string().optional().describe("Filename to write locally. (default: anchored to ./)"),
31292
+ overwrite: z3.boolean().default(false).describe("Replace an existing local file when true."),
31293
+ include_prompt: z3.boolean().default(false).describe("Include saved API JSON in structuredContent.")
31294
+ });
30940
31295
  var WaitForWorkflowSchema = z3.object({
30941
31296
  prompt_id: z3.string().describe("The prompt_id of the submitted workflow."),
30942
31297
  timeout: z3.number().int().min(1).default(120).describe("Wait timeout in seconds.")
@@ -30948,8 +31303,11 @@ async function handleInspectNode(args) {
30948
31303
  );
30949
31304
  if (args.node_class) {
30950
31305
  const node = rawInfo[args.node_class];
30951
- if (!node)
30952
- return createNotFoundError(`Node class ${args.node_class}`);
31306
+ if (!node) {
31307
+ return createNotFoundError(
31308
+ `Node class '${args.node_class}' not found. Use 'comfyui_inspect_node' without arguments to see all available nodes or use the 'query' parameter to search for it.`
31309
+ );
31310
+ }
30953
31311
  return {
30954
31312
  content: [
30955
31313
  { type: "text", text: JSON.stringify(node, null, 2) }
@@ -30999,19 +31357,7 @@ function getWorkflowId(absolutePath) {
30999
31357
  return crypto.createHash("sha256").update(absolutePath).digest("hex").substring(0, 8);
31000
31358
  }
31001
31359
  async function handleDiscoverWorkflows(args) {
31002
- const pathsToScan = [
31003
- path.join(
31004
- homedir(),
31005
- "comfy",
31006
- "ComfyUI",
31007
- "user",
31008
- "default",
31009
- "workflows"
31010
- ),
31011
- path.join(homedir(), ".fre4x-comfyui", "workflows"),
31012
- path.join(process.cwd(), "workflows")
31013
- ];
31014
- if (args.directory_path) pathsToScan.push(args.directory_path);
31360
+ const pathsToScan = getWorkflowDirectories(args.directory_path);
31015
31361
  const foundWorkflows = [];
31016
31362
  for (const dir of pathsToScan) {
31017
31363
  try {
@@ -31055,13 +31401,14 @@ async function handleDiscoverWorkflows(args) {
31055
31401
  }
31056
31402
  }
31057
31403
  if (foundWorkflows.length === 0) {
31404
+ const text2 = `No workflows found in searched directories.
31405
+
31406
+ Searched paths:
31407
+ ${pathsToScan.map((p) => `- ${p}`).join("\n")}
31408
+
31409
+ Hint: If your workflows are stored elsewhere, provide a custom path using the 'directory_path' parameter.`;
31058
31410
  return {
31059
- content: [
31060
- {
31061
- type: "text",
31062
- text: `No workflows found in searched directories.`
31063
- }
31064
- ],
31411
+ content: [{ type: "text", text: text2 }],
31065
31412
  structuredContent: {
31066
31413
  workflows: [],
31067
31414
  searched_paths: pathsToScan
@@ -31116,6 +31463,7 @@ async function handleWaitForWorkflow(args) {
31116
31463
  args.timeout
31117
31464
  );
31118
31465
  const outputsRecord = historyEntry.outputs || {};
31466
+ const workspaceOutputs = [];
31119
31467
  const content = [];
31120
31468
  let summaryText = `Workflow ${args.prompt_id} completed.
31121
31469
  `;
@@ -31123,157 +31471,194 @@ async function handleWaitForWorkflow(args) {
31123
31471
  if (output && Array.isArray(output.images)) {
31124
31472
  for (const image of output.images) {
31125
31473
  if (image.filename && image.type) {
31126
- if (!IS_MOCK) {
31127
- try {
31128
- const base643 = await fetchImageBase64(
31129
- image.filename,
31130
- image.type,
31131
- image.subfolder
31132
- );
31133
- content.push({
31134
- type: "image",
31135
- data: base643.data,
31136
- mimeType: base643.mimeType
31137
- });
31138
- summaryText += `Fetched image: ${image.filename}
31474
+ try {
31475
+ const asset = IS_MOCK ? createMockImageAsset() : await fetchImageAsset(
31476
+ image.filename,
31477
+ image.type,
31478
+ image.subfolder
31479
+ );
31480
+ content.push({
31481
+ type: "image",
31482
+ data: asset.buffer.toString("base64"),
31483
+ mimeType: asset.mimeType
31484
+ });
31485
+ summaryText += `Fetched image: ${image.filename}
31139
31486
  `;
31140
- } catch (e) {
31141
- summaryText += `Failed to fetch image ${image.filename}: ${String(e)}
31487
+ const summary = await saveToWorkspace(
31488
+ args.prompt_id,
31489
+ image,
31490
+ asset
31491
+ );
31492
+ if (summary) {
31493
+ workspaceOutputs.push(summary);
31494
+ summaryText += `Copied image to workspace: ${summary.relative_path}
31142
31495
  `;
31143
31496
  }
31144
- } else {
31145
- summaryText += `Mock image: ${image.filename}
31497
+ } catch (e) {
31498
+ summaryText += `Failed to fetch image ${image.filename}: ${String(e)}
31146
31499
  `;
31147
31500
  }
31148
31501
  }
31149
31502
  }
31150
31503
  }
31151
31504
  }
31505
+ if (IS_MOCK && outputsRecord) {
31506
+ for (const [, output] of Object.entries(outputsRecord)) {
31507
+ if (output && Array.isArray(output.images)) {
31508
+ for (const image of output.images) {
31509
+ summaryText += `Mock image: ${image.filename}
31510
+ `;
31511
+ }
31512
+ }
31513
+ }
31514
+ }
31152
31515
  content.unshift({ type: "text", text: summaryText });
31153
31516
  return {
31154
31517
  content,
31155
- structuredContent: historyEntry
31518
+ structuredContent: {
31519
+ ...historyEntry,
31520
+ workspace_outputs: workspaceOutputs
31521
+ }
31156
31522
  };
31157
31523
  } catch (error48) {
31158
31524
  return handleComfyError(error48);
31159
31525
  }
31160
31526
  }
31161
- async function handleWorkflowRun(args) {
31527
+ async function handleGetWorkflow(args) {
31162
31528
  try {
31163
- let content = "";
31164
- let targetPath = "";
31165
- if (args.workflow_file_path) {
31166
- targetPath = args.workflow_file_path;
31167
- try {
31168
- if (!targetPath.includes(path.sep)) {
31169
- const paths = [
31170
- path.join(
31171
- homedir(),
31172
- "comfy",
31173
- "ComfyUI",
31174
- "user",
31175
- "default",
31176
- "workflows",
31177
- targetPath
31178
- ),
31179
- path.join(
31180
- homedir(),
31181
- ".fre4x-comfyui",
31182
- "workflows",
31183
- targetPath
31184
- ),
31185
- path.join(process.cwd(), "workflows", targetPath)
31186
- ];
31187
- let found = false;
31188
- for (const p of paths) {
31189
- try {
31190
- content = await fs.readFile(p, "utf8");
31191
- targetPath = p;
31192
- found = true;
31193
- break;
31194
- } catch (_e) {
31195
- }
31196
- }
31197
- if (!found) {
31198
- throw new Error(
31199
- `Workflow file not found: ${targetPath}`
31200
- );
31201
- }
31202
- } else {
31203
- targetPath = await validateWorkflowPath(targetPath);
31204
- content = await fs.readFile(targetPath, "utf8");
31205
- }
31206
- } catch (err) {
31207
- return handleComfyError(err);
31208
- }
31209
- } else if (args.workflow_id) {
31210
- const pathsToScan = [
31211
- path.join(
31212
- homedir(),
31213
- "comfy",
31214
- "ComfyUI",
31215
- "user",
31216
- "default",
31217
- "workflows"
31218
- ),
31219
- path.join(homedir(), ".fre4x-comfyui", "workflows"),
31220
- path.join(process.cwd(), "workflows")
31221
- ];
31222
- let found = false;
31223
- for (const dir of pathsToScan) {
31224
- try {
31225
- const entries = await fs.readdir(dir, {
31226
- withFileTypes: true
31227
- });
31228
- for (const entry of entries) {
31229
- if (entry.isFile() && entry.name.endsWith(".json")) {
31230
- const fullPath = path.join(dir, entry.name);
31231
- const defaultId = getWorkflowId(fullPath);
31232
- if (args.workflow_id === defaultId || args.workflow_id === entry.name.replace(/\.json$/, "")) {
31233
- content = await fs.readFile(fullPath, "utf8");
31234
- targetPath = fullPath;
31235
- found = true;
31236
- break;
31237
- }
31238
- }
31529
+ const loadedWorkflow = await loadWorkflow(args);
31530
+ const editableInputs = analyzeEditableInputs(loadedWorkflow.prompt);
31531
+ const workflowId = getWorkflowId(loadedWorkflow.targetPath);
31532
+ const text = [
31533
+ `Workflow ${workflowId} loaded from ${loadedWorkflow.targetPath}.`,
31534
+ loadedWorkflow.sourceFormat === "webui" ? "Source format: Web UI (normalized to API format)." : "Source format: API format.",
31535
+ `Editable inputs (${editableInputs.length}):`,
31536
+ formatEditableInputsText(editableInputs)
31537
+ ].join("\n");
31538
+ const structuredContent = {
31539
+ workflow_id: workflowId,
31540
+ path: loadedWorkflow.targetPath,
31541
+ source_format: loadedWorkflow.sourceFormat,
31542
+ editable_inputs: editableInputs
31543
+ };
31544
+ if (args.include_prompt) {
31545
+ const filteredPrompt = {};
31546
+ for (const [nodeId, node] of Object.entries(
31547
+ loadedWorkflow.prompt
31548
+ )) {
31549
+ if (isRecord(node)) {
31550
+ const classType = typeof node.class_type === "string" ? node.class_type : "";
31551
+ if (classType && !["Note", "MarkdownNote", "PrimitiveNode"].includes(
31552
+ classType
31553
+ ) && nodeId !== "meta") {
31554
+ filteredPrompt[nodeId] = node;
31239
31555
  }
31240
- if (found) break;
31241
- } catch (_e) {
31242
31556
  }
31243
31557
  }
31244
- if (!found) {
31245
- throw new Error(`Workflow ID not found: ${args.workflow_id}`);
31246
- }
31558
+ structuredContent.prompt = filteredPrompt;
31559
+ }
31560
+ return {
31561
+ content: [{ type: "text", text }],
31562
+ structuredContent
31563
+ };
31564
+ } catch (error48) {
31565
+ return handleComfyError(error48);
31566
+ }
31567
+ }
31568
+ async function handleSaveWorkflow(args) {
31569
+ try {
31570
+ let prompt;
31571
+ let sourcePath;
31572
+ if (args.workflow !== void 0) {
31573
+ const normalized = await normalizeWorkflowPrompt(args.workflow);
31574
+ prompt = normalized.prompt;
31247
31575
  } else {
31248
- throw new Error(
31249
- "Must provide either workflow_file_path or workflow_id."
31576
+ if (!args.workflow_file_path && !args.workflow_id) {
31577
+ return createValidationError(
31578
+ "workflow",
31579
+ "Provide workflow JSON or an existing workflow reference."
31580
+ );
31581
+ }
31582
+ const loadedWorkflow = await loadWorkflow(args);
31583
+ prompt = loadedWorkflow.prompt;
31584
+ sourcePath = loadedWorkflow.targetPath;
31585
+ }
31586
+ if (args.overrides) {
31587
+ applyOverrides(prompt, args.overrides);
31588
+ }
31589
+ const outputFileName = deriveSavedWorkflowFileName(
31590
+ sourcePath,
31591
+ args.output_file_name
31592
+ );
31593
+ if (outputFileName.includes("..")) {
31594
+ return createValidationError(
31595
+ "output_file_name",
31596
+ "Directory traversal sequences (..) are not allowed."
31250
31597
  );
31251
31598
  }
31252
- let promptObj = {};
31599
+ const outputPath = path.resolve(process.cwd(), outputFileName);
31600
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
31601
+ let existedBeforeSave = false;
31253
31602
  try {
31254
- promptObj = JSON.parse(content);
31255
- } catch (e) {
31256
- throw new Error(
31257
- `SyntaxError: Invalid JSON in ${targetPath}: ${e instanceof Error ? e.message : String(e)}`
31258
- );
31603
+ await fs.access(outputPath);
31604
+ existedBeforeSave = true;
31605
+ } catch {
31606
+ existedBeforeSave = false;
31259
31607
  }
31260
- if (isWebUIFormat(promptObj)) {
31261
- let objectInfo;
31262
- if (IS_MOCK) {
31263
- objectInfo = MOCK_FIXTURES.object_info;
31264
- } else {
31265
- objectInfo = await fetchComfyJson(
31266
- "/object_info"
31267
- );
31268
- }
31269
- promptObj = convertWebUIToAPI(
31270
- promptObj,
31271
- objectInfo
31608
+ if (existedBeforeSave && !args.overwrite) {
31609
+ return createValidationError(
31610
+ "output_file_name",
31611
+ `File already exists: ${outputFileName}. Set overwrite=true to replace it.`
31272
31612
  );
31273
31613
  }
31274
- if (promptObj.prompt && typeof promptObj.prompt === "object") {
31275
- promptObj = promptObj.prompt;
31614
+ await fs.writeFile(
31615
+ outputPath,
31616
+ `${JSON.stringify(prompt, null, 2)}
31617
+ `,
31618
+ "utf8"
31619
+ );
31620
+ const editableInputs = analyzeEditableInputs(prompt);
31621
+ const workflowId = getWorkflowId(outputPath);
31622
+ const structuredContent = {
31623
+ workflow_id: workflowId,
31624
+ path: outputPath,
31625
+ source_path: sourcePath,
31626
+ overwritten: existedBeforeSave,
31627
+ editable_inputs: editableInputs
31628
+ };
31629
+ if (args.include_prompt) {
31630
+ const filteredPrompt = {};
31631
+ for (const [nodeId, node] of Object.entries(prompt)) {
31632
+ if (isRecord(node)) {
31633
+ const classType = typeof node.class_type === "string" ? node.class_type : "";
31634
+ if (classType && !["Note", "MarkdownNote", "PrimitiveNode"].includes(
31635
+ classType
31636
+ ) && nodeId !== "meta") {
31637
+ filteredPrompt[nodeId] = node;
31638
+ }
31639
+ }
31640
+ }
31641
+ structuredContent.prompt = filteredPrompt;
31276
31642
  }
31643
+ const persistedChanges = args.overrides && Object.keys(args.overrides).length > 0 ? `Persisted ${Object.keys(args.overrides).length} override(s).` : "No overrides were applied.";
31644
+ const text = [
31645
+ `Saved workflow ${workflowId} to ${outputPath}.`,
31646
+ persistedChanges,
31647
+ `Editable inputs (${editableInputs.length}):`,
31648
+ formatEditableInputsText(editableInputs)
31649
+ ].join("\n");
31650
+ return {
31651
+ content: [{ type: "text", text }],
31652
+ structuredContent
31653
+ };
31654
+ } catch (error48) {
31655
+ return handleComfyError(error48);
31656
+ }
31657
+ }
31658
+ async function handleWorkflowRun(args) {
31659
+ try {
31660
+ const { prompt } = await loadWorkflow(args);
31661
+ const promptObj = { ...prompt };
31277
31662
  if (args.overrides) {
31278
31663
  applyOverrides(promptObj, args.overrides);
31279
31664
  }
@@ -31307,7 +31692,9 @@ async function handleWorkflowRun(args) {
31307
31692
  content: [
31308
31693
  {
31309
31694
  type: "text",
31310
- text: `Workflow submitted. prompt_id: ${result.prompt_id}, queue: ${result.number}`
31695
+ text: `Workflow submitted. prompt_id: ${result.prompt_id}, queue: ${result.number}.
31696
+
31697
+ To retrieve the final results or check its status later, use the 'comfyui_wait_for_workflow' tool with this prompt_id.`
31311
31698
  }
31312
31699
  ],
31313
31700
  structuredContent: result
@@ -31339,6 +31726,18 @@ function createServer() {
31339
31726
  WorkflowRunSchema.shape,
31340
31727
  handleWorkflowRun
31341
31728
  );
31729
+ server.tool(
31730
+ "comfyui_get_workflow",
31731
+ "Inspect a stored workflow and list editable inputs.",
31732
+ GetWorkflowSchema.shape,
31733
+ handleGetWorkflow
31734
+ );
31735
+ server.tool(
31736
+ "comfyui_save_workflow",
31737
+ "Save a workflow into ./workflows with optional edits.",
31738
+ SaveWorkflowSchema.shape,
31739
+ handleSaveWorkflow
31740
+ );
31342
31741
  server.tool(
31343
31742
  "comfyui_wait_for_workflow",
31344
31743
  "Wait for a submitted workflow to finish and fetch its images.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fre4x/comfyui",
3
- "version": "1.0.61",
3
+ "version": "1.0.64",
4
4
  "description": "MCP server for ComfyUI. Execute workflows and probe server state remotely.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",