comfyui-loop-mcp: subgraphs that expand, video the loop can see, and a name I gave up

The loop server got its largest update since I open-sourced it. Subgraphs expand instead of being skipped, the looking tools finally work on video, a pre-flight answers "will this graph run here" before a GPU minute is spent, and the package has a different name than it did last week. The repo is here.
The name
Comfy-Org shipped their own local server as comfy-mcp: same PyPI name, same import package, first commit one day after mine (theirs was public first). Arguing about a namespace with the people who own the namespace is a losing position, and I'd rather spend the time on the code. The distribution is now comfyui-loop-mcp, the import package is comfy_loop, and the command matches the distribution. Runs already on your disk keep working, since the pre-rename ~/.comfy-mcp/runs directory is read until it's empty.
While updating the README I also fixed something that bothered me more than the name. My comparison table had described this project as "hobby code, one author" next to their "production, ComfyUI team," which gave away the wrong thing. The real difference is scope. A platform vendor is the right owner for the whole install surface, because that surface moves whenever ComfyUI, comfy-cli, or the partner APIs move. I own the loop, because the loop is the part Alienrobot is on the hook for in its own production work.
Subgraphs expand now
A subgraph is a canvas convenience. It exists in the litegraph JSON that the ComfyUI editor saves, and /prompt has never heard of it. My converter used to skip subgraph instances, which sounds conservative and isn't: skipping the instance doesn't produce a smaller graph, it produces one with a hole where a pipe used to be. That matters more than I expected, because 254 of the roughly 550 templates in the bundled catalog reference subgraphs.
comfy_loop/subgraph.py expands them properly. Interiors come through with namespaced ids in the form <instance>:<inner>, links get rewired across both the input and output boundary, nesting recurses, and promoted widgets keep their values. I verified it against 12 real catalog templates and got 12 clean conversions.
Two assumptions I started with were wrong, and the shipped template files are what disproved them. The boundary maps by slot name, not by index, because an instance only lists the sockets it actually draws and lists them in its own order. And widgets_values runs positionally over the definition's slots while skipping any slot that's wired.
The bug that ran fine
That second discovery exposed something live in the existing code, and it's the reason I'm writing this section at all.
When you convert a widget to an input in ComfyUI, the widget still occupies its position in widgets_values. My code skipped it. Every later widget then shifted up by one, silently.
What that produces is not a crash. An EmptySD3LatentImage with width and height wired handed 1024 to batch_size. A KSampler with a promoted seed received the seed integer where the sampler name belongs. The graph runs. It queues, it executes, it returns images, and nothing in any error log suggests you just asked for a batch of 1024 latents.
This is the exact failure class the whole project exists for. A run with zero node_errors is valid, not correct, and the only place the difference shows up is in the pixels. I've been saying that about mangled hands and matte edges since the first commit; it turns out my own converter was a fine example.
Video: the loop could not see mp4
get_result has always reported gifs and videos next to images, so a video workflow would hand the model a filename. Every looking tool downstream of that was Pillow-only, and Pillow cannot decode an mp4. For VHS, AnimateDiff, or WAN graphs, the loop's central instruction (call get_image on each output and look) was literally unexecutable. The model received a filename it could not open, and judgment collapsed back to vibes, which is the thing the ratchet exists to prevent.
Four new tools restore it by turning video back into images: video_info, get_video_frame, compare_video_frames, and video_temporal_stats.
Using ffmpeg here is a deliberate exception. imaging.py stays Pillow-only because an MCP client has no shell, but anything capable of emitting an mp4 already shipped ffmpeg (VHS_VideoCombine shells out to it), so an install that can write one can read one. When it genuinely isn't there, the tools say so plainly instead of failing with a decode error.
The trap worth knowing about: comparing two clips by timestamp is wrong the moment their lengths differ. A frame cap, a trimmed input, or a different fps lands you on different moments, and you draw a confident conclusion from two unrelated expressions. Everything here indexes by frame number, and compare_video_frames refuses a quietly-wrong comparison by flagging a frame-count mismatch rather than rendering it anyway.
Answering the whole question before spending a GPU minute
/prompt does validate a graph, which is what node_errors is, but it validates by queueing and reports one failure at a time. A model that submits in order to discover what's wrong burns a round trip per mistake. The failures it surfaces first are rarely the ones that matter either: a missing checkpoint and a missing node pack arrive as the same red box, though one is a download and the other needs a restart.
check_workflow reads object_info and answers all of it at once, sorted by what you have to do:
- install a node pack, because the class doesn't exist here
- fetch a model, because the file isn't in that loader's list
- fix the graph, because a required input is missing or a wire points at nothing
- look again, because a value sits outside the node's declared range
Missing classes resolve to pack ids in the same pass. The logic is pure (dict in, dict out, no HTTP), so it's testable offline and the same function serves a graph you just built, a template you fetched, and a run you're about to repeat.
The rest of the surface
loop_sweep runs one input across N values with everything else held identical, and writes the value-to-prompt_id table into the run, so context compaction can't lose the mapping. It records one pass, not N.
The control tools cover the gaps that made me reach for a browser tab mid-loop: job_status reports where a run is without blocking and includes queue position, cancel_job drops a queued job without interrupting the running one, free_vram clears the cached passes that OOM the next one, comfyui_logs retrieves what a mid-execution death explains nowhere else, and update_comfyui goes through Manager and refuses to pretend a 404 was a success.
Several existing tools got more honest. check_comfyui is a real pre-flight now (versions, free VRAM, whether Manager is present, whether the queue is busy). get_result names the failing node instead of shrugging with "produced no outputs." find_missing_nodes accepts a graph you already hold. template_slots returns the template author's Note text as quoted, clearly untrusted data, since it's arbitrary text arriving from a file you downloaded.
MCP SDK 2.0 also renamed FastMCP to MCPServer and moved Image, which killed a fresh install outright. It imports either way now and the tests run under both.
What I left out on purpose
Accounts, credits, and hosted partner models are all things I decided not to absorb, and the README says so explicitly. They contradict the one promise this server makes, which is that nothing leaves your box. Owning the ComfyUI process is out for a duller reason: an HTTP client cannot start a server that isn't running.
Three new offline suites came with the work, covering subgraph expansion (19 checks), the pre-flight (12), and the control tools against a stub ComfyUI on a real socket (27). They pass under both MCP SDK versions. That brings the server to 43 tools, which is more than I'd have guessed when this was a weekend experiment about making an agent look at its own output.
This post was drafted with Claude from my commit notes. The work, the numbers, and the bug are mine.