UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

215 lines • 13.2 kB
import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { logger } from "../utils/logger.js"; import { DEAD_NAMES, retiredToolMessage } from "./vocabulary.js"; /** The JSON-RPC method whose dispatch we wrap. */ const CALL_TOOL = "tools/call"; /** * Raised when the SDK does not look the way this interception needs it to. * * Carries the specific assumption that failed, because "the redirect could not be * installed" is not actionable and "server.server._requestHandlers is not a Map — * @modelcontextprotocol/sdk internals changed" is. */ export class RetiredRedirectUnavailableError extends Error { constructor(detail) { super(`Cannot install the retired-tool-name redirect on tools/call: ${detail}. ` + `This interception depends on @modelcontextprotocol/sdk internals ` + `(server.server._requestHandlers, server._registeredTools), which are private. ` + `package.json caps the SDK at the verified minor (~1.29.0) precisely so this ` + `cannot happen by resolution alone — see src/tools/retired-redirect.ts.`); this.name = "RetiredRedirectUnavailableError"; } } function inspect(server) { // `unknown` on purpose, here and for the registration table below: the SDK's // declared types are exactly what this function refuses to take on faith. const inner = server.server; if (!inner || typeof inner !== "object") { throw new RetiredRedirectUnavailableError("server.server is missing or not an object"); } const handlers = inner._requestHandlers; if (!(handlers instanceof Map)) { throw new RetiredRedirectUnavailableError(`server.server._requestHandlers is ${handlers === undefined ? "missing" : "not a Map"}`); } const original = handlers.get(CALL_TOOL); if (typeof original !== "function") { // Reachable by our own mistake as well as by an SDK change: the SDK installs the // tools/call handler LAZILY, on the first `server.tool()`. Calling this before any // tool is registered lands here, and saying so beats blaming the dependency. throw new RetiredRedirectUnavailableError(`server.server._requestHandlers has no '${CALL_TOOL}' function ` + `(the SDK installs it on the first tool registration — install this AFTER registering tools)`); } // Bracket access is TypeScript's sanctioned way past a `private` member; the .d.ts // gives it no type, so the annotation keeps `any` from leaking out of this line. const registered = server["_registeredTools"]; if (!registered || typeof registered !== "object" || Array.isArray(registered)) { throw new RetiredRedirectUnavailableError(`server._registeredTools is ${registered === undefined ? "missing" : "not a plain object"}`); } if (Object.keys(registered).length === 0) { // Catches the one shape a `!registered` test cannot: the field still exists and is // still an object, but is not the table the SDK is filling. Treating that decoy as // the truth would mark every name unregistered and let the ledger shadow a live // tool — a wrong answer rather than a missing one. // // The honest caveat: an empty table alongside a live tools/call handler is not // strictly impossible. Registering a tool and then removing it again leaves exactly // this state, because the SDK never tears the handler back down. Nothing in this // repo does that, and both call sites install immediately after a registration // pass — and tryInstallRetiredNameRedirect degrades rather than throwing, so even // that unreachable case would cost a log line, not a startup. throw new RetiredRedirectUnavailableError(`server._registeredTools is empty although '${CALL_TOOL}' is already handled — ` + `the registration table is not where we are looking`); } return { handlers: handlers, original: original, registered: registered, }; } /** * Put the retirement ledger in front of the SDK's `tools/call` dispatch. * * Call AFTER the registration pass — the SDK installs its `tools/call` handler on the * first `server.tool()`, so there is nothing to capture before then — and BEFORE * `server.connect()`, so no request can arrive mid-swap. * * Throws `RetiredRedirectUnavailableError` if any assumption about the SDK fails, or if * the installed wrapper cannot be shown to actually answer. Production callers should * prefer {@link tryInstallRetiredNameRedirect}, which logs instead — see its comment * for why the runtime does not fail hard here. * * Async only because of the self-test at the end, which drives the installed handler * once. Nothing is in flight yet at that point: this runs before `server.connect()`. */ export async function installRetiredNameRedirect(server) { const { handlers, original, registered } = inspect(server); server.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { const name = request.params.name; // A REGISTERED name always wins, even against the ledger. The two sets cannot // overlap today — registerAutoloadedWorkflows reserves DEAD_NAMES as well as // TOOL_NAMES, so a saved workflow file whose name slugifies onto a retired tool // is skipped rather than registered — but that is another module's invariant. // If it ever regresses, a live tool // must still run rather than be shadowed by a redirect telling its caller to go // somewhere else, which is a wrong answer rather than a missing one. // // `hasOwnProperty` rather than a truthiness test on the value: the table is a // plain object, so `registered['constructor']` is a function by inheritance and // `registered['toString']` is truthy. A tool named `constructor` is a legal MCP // tool name and an autoloaded `constructor.json` produces exactly that. if (!Object.prototype.hasOwnProperty.call(registered, name)) { const message = retiredToolMessage(name); if (message) { // Shaped like the SDK's own `createToolError` — a text block with // `isError: true` — so a client that already handles a failed tool call // handles this one identically, and reads the reason out of the same place. const result = { isError: true, content: [{ type: "text", text: message }], }; return result; } } // Everything else is the SDK's business, with the request object unchanged: // argument validation, disabled tools, task augmentation, output-schema // validation, and its own "Tool <name> not found" for a name the ledger does not // know. A made-up name is NOT a retired name and must not be told it was. return (await original(request, extra)); }); // POST-CONDITION, and the point of the whole module. `setRequestHandler` is // documented to replace the handler for a method, but "replace" is only useful here // if it replaces the entry in the map we captured from and that `Protocol._onrequest` // dispatches through. If a future SDK routes registrations somewhere else, our // wrapper would be installed into a table nobody reads: every call would still reach // the SDK's handler, every retired name would 404, and nothing would say so. // // The cheap half — "the entry changed" — is only a smell test, and a misleading one. // It passes for ANY function that is not the original, so a future SDK that writes // some third thing into the slot would report the redirect as ACTIVE while this // wrapper never runs. That is the same false-acceptance shape the check exists to // prevent, one level up. It stays because it produces the clearest message for the // likeliest failure, but it does not decide anything. const installed = handlers.get(CALL_TOOL); if (typeof installed !== "function" || installed === original) { throw new RetiredRedirectUnavailableError(`setRequestHandler did not replace the '${CALL_TOOL}' entry in server.server._requestHandlers ` + `(it still holds ${installed === original ? "the SDK's original handler" : String(installed)}), ` + `so the wrapper would never run`); } // What actually decides: DRIVE the installed entry and require the redirect back. // Identity is not assertable — `setRequestHandler` stores its own closure around our // handler, so the map never holds the function we wrote — and identity would be the // weaker question anyway. This asks the only one that matters: if the dispatcher // routes a retired name through what is now in that slot, does the ledger answer? // Any rename, re-route, or reshape that leaves the wrapper unreachable fails HERE, // whatever it did to the plumbing. // // The probe is side-effect-free by construction: it names a RETIRED tool that is not // registered, so the only two outcomes are our redirect (pass) or the SDK's // "not found" (fail). No tool handler runs either way. const probe = DEAD_NAMES.find((d) => !Object.prototype.hasOwnProperty.call(registered, d.name)); if (!probe) { // Every retired name is also a live tool — impossible while the vocabulary gate and // registerAutoloadedWorkflows both hold, and it would make the whole ledger // unreachable anyway. Refuse rather than skip the self-test and claim success. throw new RetiredRedirectUnavailableError(`no retired name is available to self-test with — all ${DEAD_NAMES.length} are also registered tools`); } const expected = retiredToolMessage(probe.name); let answered; try { answered = await installed({ method: CALL_TOOL, params: { name: probe.name, arguments: {} } }, // `extra` is never dereferenced on the redirect path — the wrapper returns before // delegating — so a placeholder is honest here rather than a fake handshake. {}); } catch (err) { throw new RetiredRedirectUnavailableError(`the installed '${CALL_TOOL}' handler threw on a self-test call for the retired name ` + `'${probe.name}': ${err instanceof Error ? err.message : String(err)}`); } const text = answered?.content ?.map((c) => (c.type === "text" ? c.text : "")) .join(""); if (text !== expected) { throw new RetiredRedirectUnavailableError(`the installed '${CALL_TOOL}' handler did not serve the ledger for the retired name ` + `'${probe.name}' — expected ${JSON.stringify(expected)}, got ${JSON.stringify(text)}. ` + `Something other than this wrapper is answering tools/call`); } } /** * {@link installRetiredNameRedirect}, degraded to a loud log instead of a throw. * Returns whether the redirect is active. * * Why the runtime does not fail hard: throwing here would mean the MCP server does not * START — a total outage — in order to protect the QUALITY of one error message. * Degrading instead restores exactly the pre-fix behaviour: retired names get the SDK's * bare "Tool X not found", every live tool keeps working, and the server boots. * * That trade is only defensible because the SDK a user can resolve is BOUNDED. This * package is normally run as `npx comfyui-mcp`, which resolves dependencies fresh — * package-lock.json protects `npm ci` in CI and nothing else — so a caret range would * have made "internals nothing ever ran against" the COMMON case rather than an edge * one. package.json therefore caps `@modelcontextprotocol/sdk` below the first * unverified minor, and raising that cap is a deliberate, reviewed edit that runs the * canary in src/__tests__/tools/retired-redirect.test.ts against the new SDK. Inside * the cap the install additionally SELF-TESTS on every boot (see * installRetiredNameRedirect), so "active" is measured rather than assumed. * * What remains, said plainly: nothing here can make a degraded state visible to the * CALLER, because the wrapper is the sole thing that could have changed what the caller * sees. The log below and the boolean the call sites report are the only signals, and * they reach an operator, not a model. That is the residual cost of not bricking * startup, and it is why the cap — not the log — is what actually holds the guarantee. */ export async function tryInstallRetiredNameRedirect(server) { try { await installRetiredNameRedirect(server); return true; } catch (err) { logger.error(`[tools] retired-tool-name redirects are NOT active on direct tools/call — ` + `a call to a name removed in an earlier release will answer with the SDK's bare ` + `"Tool <name> not found" instead of naming its replacement. Live tools are unaffected. ` + (err instanceof Error ? err.message : String(err))); return false; } } //# sourceMappingURL=retired-redirect.js.map