src / mcp / index.ts
src / mcp / index.ts
import crypto from "node:crypto";
import path from "node:path";
import { fromJsonSchema } from "@modelcontextprotocol/server";
import { zodToJsonSchema } from "zod-to-json-schema";
import { z } from "zod";
import {
bridgeToolErrorResult,
startStdioMcpServer,
looksLikeSourceNotation,
normalizeSourceFieldToArray,
resolveMcpSourceToken,
checkSkillStatus,
createSkillNoticeProvider,
createSkillFileUpdateNotice,
ensureLocalSkillCache,
syncSkillToInstallTarget,
createBackgroundRequestId,
runInBackgroundJob,
getBackgroundRequest,
markBackgroundRequestDelivered,
listBackgroundRequests,
cancelBackgroundRequest,
runWithCancelSignal,
requestBelongsToScope,
createMcpProgressSender,
createProgressReporter,
type ProgressCallback,
type McpResultPreset,
} from "./core-bundle.mjs";
import { GenerateToolParamsSchemaMinimalStrict, UpscaleToolSchemaStrict, getProjectRoot } from "../core-bundle.mjs";
import { applyMcpConfig, getRawEnvSnapshot, initCustomConfigs, readMcpConfig, type McpConfig } from "./config.js";
import { bindChatContextToScratchpad } from "./chatContextBridge.js";
import { assertNoAttachmentNotation, collectAttachmentTokens } from "./sourceNotation.js";
import { logger } from "./mcpLogger.js";
import { extractSummary, extractSummaryText, materializeRegisteredImages, writeHtmlReport, type RegisteredImage } from "./resultMaterializer.js";
import { resolveEffectiveScratchpadPath } from "./scratchpadResolution.js";
import { buildToolResult, buildUnslothToolResult, generateImageToolResult, generateSingleImageToolResult } from "./toolResults.js";
type ToolArgs = Record<string, unknown> & { scratchpadFolder?: string };
/**
* aN (LM-Studio attachment notation) doesn't exist over MCP for bionic/generic (see
* sourceNotation.ts) — but the shared `.describe()` text in draw-things-chat-core/schemas.ts
* still mentions it as an example, since that same text is also the LM-Studio plugin's own tool
* description (where aN IS valid). Rewriting schemas.ts would break the plugin's description, so
* this strips the aN mention only from the JSON schema text actually sent to MCP clients, leaving
* schemas.ts untouched.
*/
// bionic/generic: the shared canvas `.describe()` lists 'a1' as just another notation; merely
// stripping it would silently drop attachments. Replace the whole notation sentence with an
// explicit attachment→basename vs notation split before the generic 'a1' cleanups run.
const CANVAS_NOTATION_OLD = "Notation: 'a1', 'v2', 'p1', 'i3'.";
const CANVAS_NOTATION_NEW =
"An attachment the user added to the chat must be referenced by its filename (basename). Generated images, pictures and variants use notations 'i3', 'p1', 'v2'.";
function stripAttachmentNotationMentions(node: unknown): unknown {
if (Array.isArray(node)) return node.map(stripAttachmentNotationMentions);
if (node === null || typeof node !== "object") return node;
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
out[key] =
key === "description" && typeof value === "string"
? value
.split(CANVAS_NOTATION_OLD).join(CANVAS_NOTATION_NEW)
.replace(/'a1', /g, "")
.replace(/ \(e\.g\. 'a1'\)/g, "")
: stripAttachmentNotationMentions(value);
}
return out;
}
const ATTACHMENT_INVENTORY_HINT =
" Pass the literal \"aN\" (or any unregistered number) to get back a list of all attachments currently available in this thread instead of running the tool.";
/**
* unsloth is the one preset where aN genuinely resolves (see sourceNotation.ts) — instead of
* stripping the aN mention, append the inventory-listing hint right after it so the agent knows
* how to discover valid numbers.
*/
function annotateAttachmentNotationForUnsloth(node: unknown): unknown {
if (Array.isArray(node)) return node.map(annotateAttachmentNotationForUnsloth);
if (node === null || typeof node !== "object") return node;
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
out[key] = key === "description" && typeof value === "string" && value.includes("'a1'") ? `${value}${ATTACHMENT_INVENTORY_HINT}` : annotateAttachmentNotationForUnsloth(value);
}
return out;
}
/**
* Adds the MCP-only scratchpadFolder field on top of the plugin's real Zod schema — no field is
* hand-duplicated. Also hides `quality`, mirroring the plugin's own tools (generate_image.ts /
* upscale.ts), which always run at the backend's "auto" default and never expose it to the agent.
* scratchpadFolder's own requiredness comes from the resolved preset (made-for-bionic-core) —
* see presets/index.ts's doc comment for why this must not be re-derived locally.
*/
function toMcpInputSchema(zodSchema: z.ZodTypeAny, requireScratchpadFolder: boolean, preset: McpResultPreset): Record<string, unknown> {
const { $schema: _$schema, ...rawSchema } = zodToJsonSchema(zodSchema) as Record<string, any>;
const schema = (preset.name === "unsloth" ? annotateAttachmentNotationForUnsloth(rawSchema) : stripAttachmentNotationMentions(rawSchema)) as Record<string, any>;
const { quality: _quality, ...visibleProperties } = (schema.properties ?? {}) as Record<string, unknown>;
const baseRequired = ((schema.required as string[] | undefined) ?? []).filter((key) => key !== "quality");
// 'refine' is an internal generation profile applied via sourceTag (see core/tools.ts's
// isRefineProfile), not a mode an agent should pick directly — hide it from the exposed enum.
const mode = visibleProperties.mode as { enum?: string[] } | undefined;
if (mode?.enum) {
visibleProperties.mode = { ...mode, enum: mode.enum.filter((m) => m !== "refine") };
}
return {
...schema,
properties: {
scratchpadFolder: {
type: "string",
description: requireScratchpadFolder
? `Required scratchpad session folder name, one level under CHAT_WORKING_DIRECTORIES. ${scratchpadFolderGuidance(preset, true)}`
: "Optional scratchpad session folder name, one level under CHAT_WORKING_DIRECTORIES. Omit to write directly into CHAT_WORKING_DIRECTORIES.",
},
...visibleProperties,
},
required: requireScratchpadFolder ? ["scratchpadFolder", ...baseRequired] : baseRequired,
};
}
function sourceNotationGuidance(preset: McpResultPreset): string {
const base = "Sources (canvas/moodboard): pN (picture), iN (image, including a file this tool generated earlier), a filename in the scratchpad folder, or an absolute accessible path.";
if (preset.name !== "unsloth") return `${base} A file the user attached to the chat must be referenced by its filename (basename).`;
return `${base} Also accepts aN (attachment, e.g. a1).${ATTACHMENT_INVENTORY_HINT}`;
}
/**
* unsloth has no get_scratchpad_folder tool (see made-for-preset-rollout-plan.md) — its agent
* obtains the same value via \`cat .unsloth_sandbox\`. Reusing bionic's "get_scratchpad_folder"
* wording there would be actively wrong, not just imprecise. generic must never name a specific
* tool, not even speculatively ("if your host has X") — a generic/third-party MCP client can't be
* assumed to have any particular tool at all.
*/
function scratchpadFolderGuidance(preset: McpResultPreset, requireScratchpadFolder: boolean): string {
if (!requireScratchpadFolder) return "scratchpadFolder is optional — omit it to use the server's configured working directory, or pass an existing session folder name if your own setup already manages one.";
if (preset.name === "unsloth") return 'Before calling this tool: run `cat .unsloth_sandbox` and pass its output unchanged as scratchpadFolder — its output is an identifier such as "__LOCALID_htL7iV3", e.g. scratchpadFolder="__LOCALID_htL7iV3".';
return 'Before calling this tool: call get_scratchpad_folder, then pass its return value unchanged as scratchpadFolder — e.g. if get_scratchpad_folder returns "1a2b3c", call this tool with scratchpadFolder="1a2b3c".';
}
// Deliberately minimal — scratchpadFolder wrapping (toMcpInputSchema) is the only shared
// concern here, there's no canvas/model/notation surface to carry over from generate_image.
const RequestProgressSchema = z
.object({
requestId: z.string().optional().describe("The requestId returned by generate_image. Omit to list every background request for this scratchpad."),
cancel: z.boolean().optional().describe("If true, cancels the request given by requestId instead of checking it. Queued requests are removed from the queue, running ones are aborted."),
})
.strict();
/**
* Full model-preset list for the MCP generate_image schema. The MCP tool description stays brief
* (one example); this authoritative list lives in the schema so the agent never guesses a preset.
* Keep in sync with the plugin's src/tools/generate_image.ts model line and draw-things-chat-core
* capabilities.ts — see model-preset-registration-checklist.md.
*/
const GENERATE_MODEL_FIELD_DESCRIPTION =
"Model preset. One of: auto (default), z-image, krea, qwen-image, qwen-image-2.1, qwen-image-2.1-turbo, flux, ltx, minimax, minimax-ref, custom, or a user-defined entry from custom_configs.json. 'auto' selects the best model for the mode. Video modes (text2video/image2video) require a video-capable model: ltx, minimax, or minimax-ref.";
// Stage 1 of the SKILL.md migration (see made-for-bionic/instructions/agent_tools/
// tool-description-skill-concept.md): useSkills=true keeps the tool-level description minimal,
// routing to the installed skill; full parameter semantics then live in
// skills/generate-image/tools/<tool>.md. useSkills=false (e.g. no skill installed for this
// client) restores the original, fully inline description. Field descriptions
// (scratchpadFolderGuidance, sourceNotation, model preset list, ...) are untouched either way.
function generateImageDescription(preset: McpResultPreset, requireScratchpadFolder: boolean, useSkills: boolean): string {
if (useSkills) {
return "Generate an image or video using Draw Things. Before calling: read skill 'generate-image' (SKILL.md for an overview, tools/generate_image.md for modes, model presets, and source notation).";
}
return `Generate an image or video using Draw Things.
${scratchpadFolderGuidance(preset, requireScratchpadFolder)}
${sourceNotationGuidance(preset)}
Pick a model with the \`model\` parameter (e.g. \`qwen-image-2.1\`); the full preset list is in its schema. Omit it to let \`auto\` choose.`;
}
function upscaleDescription(preset: McpResultPreset, requireScratchpadFolder: boolean, useSkills: boolean): string {
if (useSkills) {
return "Re-render the canvas at a higher resolution via Draw Things image2image. No crop — uses the full canvas as-is. Before calling: read skill 'generate-image' (SKILL.md for an overview, tools/upscale.md for parameter details).";
}
return `Re-render the canvas at a higher resolution via Draw Things image2image. No crop — uses the full canvas as-is.
${scratchpadFolderGuidance(preset, requireScratchpadFolder)}
${sourceNotationGuidance(preset)}`;
}
function requestProgressDescription(): string {
return "Check, retrieve or cancel generate_image renders started with runInBackground: true. Normal use: pass requestId (the id generate_image returned) — while the render runs this shows its progress, once it has finished it returns the full result including the image, which is the only way to receive a background image. To CANCEL a request (queued or running), pass requestId and cancel: true. Without requestId the tool only lists the status of all requests for this scratchpad (no images); use that just to recover a lost requestId. Do not call this again immediately after a non-completed result — wait for the user's next message or other work first.";
}
const COMPLETED_LIST_HINT = "This list shows statuses only. To evaluate a finished (completed) image, call request_progress with its requestId.";
// The MCP SDK's CallToolResult content-block union isn't re-exported for reuse here,
// so the handler result stays structurally typed (any) rather than hand-duplicating it.
function toToolResult(result: unknown): any {
const record = result as { content?: unknown; isError?: unknown } | undefined;
if (record && Array.isArray(record.content)) {
return record.isError === true ? { content: record.content, isError: true } : { content: record.content };
}
return { content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result) }] };
}
/**
* Runs a foreground render so that cancelling the MCP request (notifications/cancelled → ctx.mcpReq.signal)
* closes the gRPC stream instead of letting the render run to completion.
*/
function runWithRequestCancel<T>(ctx: unknown, fn: () => Promise<T>): Promise<T> {
const signal = (ctx as { mcpReq?: { signal?: AbortSignal } } | undefined)?.mcpReq?.signal;
return signal ? runWithCancelSignal(signal, fn) : fn();
}
/**
* Forwards handleGenerateImage/handleUpscale's ProgressCallback to MCP notifications/progress
* (same wording and ETA as the LM Studio plugin's ctx.status, both from made-for-bionic-core's
* progressReporter). undefined unless the client sent a progressToken.
*/
function createProgressForwarder(ctx: unknown, tool: string): ProgressCallback | undefined {
const send = createMcpProgressSender(ctx, (error) => {
logger.logError("progress-notify-failed", error, { tool }).catch(() => {});
});
return send ? createProgressReporter(send) : undefined;
}
/**
* Only the unsloth preset defines beforeToolCall (see made-for-bionic-core's presets/unsloth.ts)
* — it re-syncs newly discovered attachments into chat_media_state.json and validates any aN
* token this call referenced. `threadId` is the raw scratchpadFolder value the client passed:
* for unsloth that IS the chat_threads.id (see `cat .unsloth_sandbox`), not a resolved path.
*/
async function runBeforeToolCallHook(config: McpConfig, toolArgs: ToolArgs, scratchpadPath: string, renderArgs: Record<string, unknown>): Promise<{ blocked: true; message: string } | { blocked: false }> {
if (!config.preset.beforeToolCall) return { blocked: false };
return config.preset.beforeToolCall({
scratchpadPath,
threadId: toolArgs.scratchpadFolder ?? "",
clientDbPath: config.clientDbLocation,
attachmentTokens: collectAttachmentTokens(renderArgs),
});
}
/**
* Resolves a basename/absolute-path/preview-filename `canvas`/`moodboard` value (the 3
* non-aN/vN/iN/pN MCP input shapes — see made-for-bionic-core's resolveMcpSourceToken()) into a
* containment-checked absolute path to the ORIGINAL file, BEFORE core/tools.ts's handler ever sees
* it. aN/vN/iN/pN values are left untouched for the handler's own notation parser — including a
* real `aN` for unsloth: core/tools.ts's detectLabel-driven crop logic and cropSource/detectSource
* redirects match the raw `canvas` string against stored notations verbatim, so rewriting `aN` to
* a path here would silently break that re-matching. The `attachment` pool's own LM-Studio-only
* resolver is instead fixed locally inside core/tools.ts (see resolveAttachmentFromState()).
* Mutates `renderArgs` in place.
*/
async function preResolveCanvasField(renderArgs: Record<string, unknown>, field: string, scratchpadPath: string): Promise<void> {
const raw = renderArgs[field];
if (raw === undefined) return;
const wasArray = Array.isArray(raw);
const tokens = normalizeSourceFieldToArray(raw);
if (tokens.length === 0) return;
const resolved: string[] = [];
for (const token of tokens) {
if (looksLikeSourceNotation(token)) {
resolved.push(token);
continue;
}
const abs = await resolveMcpSourceToken(token, scratchpadPath);
resolved.push(abs ?? token);
}
renderArgs[field] = wasArray || resolved.length > 1 ? resolved : resolved[0];
}
/**
* Runs a render call, takes the iN entries it registered from its own result
* (`registeredImages`, see resultMaterializer.ts) and turns them into a tool result, per the resolved
* preset (made-for-bionic-core's presets/{bionic,generic}.ts — the single source of truth for
* base64/HTML-report/scratchpadFolder behavior, shared by every Bionic MCP bridge):
* - includeBase64Preview: the generic, client-agnostic shape (buildToolResult) — inline base64
* preview(s), http/file links, and a generic usage hint, no host-specific tool references.
* compactToolResult (COMPACT_TOOL_RESULT env var) drops the http/file links and the
* "show this to the user" sentence, merging a presented-already sentence into the usage hint.
* - otherwise: Bionic-guided tool-chaining text (open_url_in_app_browser -> view_images ->
* attach_file) — an HTML report for 2+ results (generateImageToolResult, gated by
* writeHtmlReportForMultipleResults) or the file's own preview inlined for exactly 1 result
* (generateSingleImageToolResult). compactToolResult has no effect here.
* Falls back to the renderer's raw content when nothing new was recorded (e.g. an early
* validation error inside handleGenerateImage/handleUpscale itself).
*/
async function runAndMaterialize(
tool: "generate_image" | "upscale",
scratchpadPath: string,
scratchpadFolder: string | undefined,
preset: McpResultPreset,
compactToolResult: boolean,
renderArgs: Record<string, unknown>,
render: () => Promise<unknown>
): Promise<any> {
const result = await render();
const record = result as { content?: unknown; isError?: unknown; registeredImages?: RegisteredImage[] } | undefined;
if (record?.isError === true) return toToolResult(result);
const materialized = await materializeRegisteredImages(scratchpadPath, record?.registeredImages ?? []);
if (materialized.length === 0) return toToolResult(result);
if (preset.includeBase64Preview) {
const summaryText = extractSummaryText(result);
await logger.logEvent("tool-materialized", { tool, scratchpadPath, notations: materialized.map((m) => m.notation).join(",") });
if (preset.name === "unsloth") {
return buildUnslothToolResult(tool, scratchpadPath, scratchpadFolder, materialized, summaryText, compactToolResult);
}
return buildToolResult(tool, scratchpadPath, scratchpadFolder, materialized, summaryText, true, compactToolResult);
}
// Single-result case: no HTML report — the in-app browser opens the file's own preview, and
// metadata is inlined directly from the tool call's own summary (see toolResults.ts).
if (materialized.length === 1 || !preset.writeHtmlReportForMultipleResults) {
const summary = extractSummary(result);
await logger.logEvent("tool-materialized", { tool, scratchpadPath, notations: materialized[0]!.notation });
return { content: [{ type: "text", text: generateSingleImageToolResult(tool, materialized[0]!, summary) }] };
}
// Named after the tool's own audit requestId (see core/tools.ts, generate-image-plugin.audit.jsonl)
// so the report file can be matched back to its audit log entry. Falls back to a short random id
// in the unlikely case the summary doesn't carry one.
const summary = extractSummary(result);
const requestId = typeof summary?.requestId === "string" && summary.requestId.trim() ? summary.requestId : crypto.randomUUID().slice(0, 8);
const reportPath = await writeHtmlReport(
scratchpadPath,
requestId,
{
tool,
prompt: typeof renderArgs.prompt === "string" ? renderArgs.prompt : undefined,
canvas: typeof renderArgs.canvas === "string" ? renderArgs.canvas : undefined,
summary,
},
materialized
);
await logger.logEvent("tool-materialized", { tool, scratchpadPath, notations: materialized.map((m) => m.notation).join(","), reportPath });
return { content: [{ type: "text", text: generateImageToolResult(tool, reportPath, materialized) }] };
}
async function main(): Promise<void> {
// Logged BEFORE readMcpConfig() (which throws on a missing required var) so a full,
// unfiltered record of what Bionic actually passed in always reaches generate-image-mcp.log —
// every var, whether explicitly set, defaulted, or unset/empty.
await logger.logEvent("env snapshot (raw)", getRawEnvSnapshot());
const config = readMcpConfig();
applyMcpConfig(config);
const customConfigsOutcome = await initCustomConfigs(config.customConfigsPath);
await logger.logEvent("Custom Configs", { ...customConfigsOutcome });
// Single source of truth (made-for-bionic-core) for base64/HTML-report/scratchpadFolder
// behavior — computed once in config.ts and threaded through, never re-derived below.
const preset = config.preset;
await logger.logEvent("MCP server starting", {
drawThingsHost: config.drawThingsHost,
drawThingsHttpPort: config.drawThingsHttpPort,
drawThingsGrpcPort: config.drawThingsGrpcPort,
preset: preset.name,
presetIncludeBase64Preview: preset.includeBase64Preview,
presetWriteHtmlReportForMultipleResults: preset.writeHtmlReportForMultipleResults,
presetScratchpadFolderRequired: preset.scratchpadFolderRequired,
embedPngMetadata: config.embedPngMetadata,
httpServerPort: config.httpServerPort,
chatWorkingDirectories: config.chatWorkingDirectories,
useSkills: config.useSkills,
autoUpdateSkills: config.autoUpdateSkills,
nodeVersion: process.version,
});
// Computed once per server run (one run ~= one client session) rather than per tool call —
// see tool-description-skill-concept.md's "USE_SKILLS-Gate" section for why this must never be
// mentioned unconditionally inside SKILL.md itself (that would make every agent re-check on
// every read instead of being told, at most once, exactly when it matters). 'image-display' is
// checked the same way as 'generate-image' itself — a passive "## Related skills" mention in
// SKILL.md isn't a reliable enough link, since nothing forces an agent to read that section
// before displaying a result (see tool-description-skill-concept.md's "Architekturentscheidung 5").
// Step 1 (local cache first, see tool-description-skill-concept.md): keep skills/<name>/ in
// this plugin's own install directory current from the public GitHub mirror, but only hit
// GitHub when the cache doesn't match this plugin build's own version — never on every session.
const projectRoot = getProjectRoot();
if (config.autoUpdateSkills) {
const cacheResult = await ensureLocalSkillCache("generate-image", { projectRoot });
await logger.logEvent("skill-cache", { skill: "generate-image", result: cacheResult });
const imageDisplayCacheResult = await ensureLocalSkillCache("image-display", { projectRoot });
await logger.logEvent("skill-cache", { skill: "image-display", result: imageDisplayCacheResult });
}
// Step 2: compare the client-installed copy against that (now-fresh) local cache.
const skillStatus = checkSkillStatus(preset, config.useSkills, "generate-image", "generate-image", { projectRoot });
const imageDisplaySkillStatus = checkSkillStatus(preset, config.useSkills, "image-display", "generate-image", { projectRoot });
await logger.logEvent("skill-status", { skill: "generate-image", ...skillStatus });
await logger.logEvent("skill-status", { skill: "image-display", ...imageDisplaySkillStatus });
// Step 3: propagate to the install target, per file — never when the user has edited that
// specific file (userModified), and never at all when AUTO_UPDATE_SKILLS=false (manual install
// scripts only). Runs unconditionally (not gated on skillStatus.state) so a file whose own
// content changed (e.g. models/qwen-image.md) still gets synced even when SKILL.md itself is
// unchanged — see made-for-bionic-core's syncSkillToInstallTarget() for the per-file logic.
let effectiveSkillStatus: typeof skillStatus = skillStatus;
let skillFileUpdateNotice: string | undefined;
if (config.useSkills && config.autoUpdateSkills) {
const syncResult = syncSkillToInstallTarget("generate-image", preset, { projectRoot });
await logger.logEvent("skill-sync", { skill: "generate-image", status: syncResult.status, updatedFiles: syncResult.updatedFiles.join(","), userModifiedFiles: syncResult.userModifiedFiles.join(",") });
if (syncResult.status !== "failed" && (skillStatus.state === "not-installed" || (skillStatus.state === "stale" && !skillStatus.userModified))) {
effectiveSkillStatus = { state: "current" };
}
skillFileUpdateNotice = createSkillFileUpdateNotice("generate-image", projectRoot, syncResult.userModifiedFiles);
}
let effectiveImageDisplaySkillStatus: typeof imageDisplaySkillStatus = imageDisplaySkillStatus;
let imageDisplayFileUpdateNotice: string | undefined;
if (config.useSkills && config.autoUpdateSkills) {
const syncResult = syncSkillToInstallTarget("image-display", preset, { projectRoot });
await logger.logEvent("skill-sync", { skill: "image-display", status: syncResult.status, updatedFiles: syncResult.updatedFiles.join(","), userModifiedFiles: syncResult.userModifiedFiles.join(",") });
if (syncResult.status !== "failed" && (imageDisplaySkillStatus.state === "not-installed" || (imageDisplaySkillStatus.state === "stale" && !imageDisplaySkillStatus.userModified))) {
effectiveImageDisplaySkillStatus = { state: "current" };
}
imageDisplayFileUpdateNotice = createSkillFileUpdateNotice("image-display", projectRoot, syncResult.userModifiedFiles);
}
// Point 1: if the skill still isn't actually available/current (sync failed, or the user's own
// edited copy is being respected), fall back to the full inline tool description instead of a
// dangling pointer at a skill the agent may not be able to read usefully.
const effectiveUseSkillsForDescriptions = config.useSkills && effectiveSkillStatus.state === "current";
const getSkillNotice = createSkillNoticeProvider(effectiveSkillStatus, "generate-image");
const getImageDisplaySkillNotice = createSkillNoticeProvider(effectiveImageDisplaySkillStatus, "image-display");
let skillFileNoticesShown = false;
function getSkillFileNotices(): string[] {
if (skillFileNoticesShown) return [];
skillFileNoticesShown = true;
return [skillFileUpdateNotice, imageDisplayFileUpdateNotice].filter((notice): notice is string => Boolean(notice));
}
function withSkillNotice(result: any): any {
const notices = [getSkillNotice(), getImageDisplaySkillNotice(), ...getSkillFileNotices()].filter((notice): notice is string => Boolean(notice));
if (notices.length === 0 || !result || !Array.isArray(result.content)) return result;
return { ...result, content: [...result.content, ...notices.map((text) => ({ type: "text", text }))] };
}
startStdioMcpServer({
name: "generate-image-mcp",
version: "0.1.0",
buildServer: (server) => {
const generateInputSchema = toMcpInputSchema(GenerateToolParamsSchemaMinimalStrict, preset.scratchpadFolderRequired, preset);
const generateModelProp = (generateInputSchema.properties as Record<string, any>).model as Record<string, unknown> | undefined;
if (generateModelProp) {
(generateInputSchema.properties as Record<string, any>).model = { ...generateModelProp, description: GENERATE_MODEL_FIELD_DESCRIPTION };
}
server.registerTool(
"generate_image",
{ description: generateImageDescription(preset, preset.scratchpadFolderRequired, effectiveUseSkillsForDescriptions), inputSchema: fromJsonSchema(generateInputSchema as any) },
async (args: unknown, ctx: unknown): Promise<any> => {
const toolArgs = args as ToolArgs & { runInBackground?: boolean };
try {
const scratchpadPath = await resolveEffectiveScratchpadPath(config.chatWorkingDirectories, toolArgs.scratchpadFolder, preset.scratchpadFolderRequired, preset);
await logger.logEvent("tool-request", { tool: "generate_image", scratchpadPath });
// quality is hidden from the schema above; also strip it here so it can never reach the
// backend even if the caller sends it anyway — always resolves to the "auto" default.
// runInBackground is a transport-level flag handled right here, never a render param.
const { scratchpadFolder: _scratchpadFolder, quality: _quality, runInBackground, ...renderArgsRaw } = toolArgs;
assertNoAttachmentNotation(renderArgsRaw, preset);
const renderArgs = renderArgsRaw;
const hookResult = await runBeforeToolCallHook(config, toolArgs, scratchpadPath, renderArgs);
if (hookResult.blocked) return { content: [{ type: "text", text: hookResult.message }] };
await preResolveCanvasField(renderArgs, "canvas", scratchpadPath);
await preResolveCanvasField(renderArgs, "moodboard", scratchpadPath);
bindChatContextToScratchpad(scratchpadPath);
if (runInBackground === true) {
const requestId = createBackgroundRequestId(path.basename(scratchpadPath));
// Awaited: guarantees the record exists on disk before this tool call returns, so an
// immediate request_progress call in the same turn can never race ahead of it.
await runInBackgroundJob({
scratchpadPath,
requestId,
tool: "generate_image",
run: (bgOnProgress) =>
runAndMaterialize("generate_image", scratchpadPath, toolArgs.scratchpadFolder, preset, config.compactToolResult, renderArgs, async () => {
const { handleGenerateImage } = await import("../core/tools.js");
return handleGenerateImage(renderArgs, bgOnProgress, { requestIdOverride: requestId });
}),
});
return withSkillNotice({
content: [
{
type: "text",
text: `Generation started in the background (requestId: ${requestId}). Use request_progress with this requestId to check status or retrieve the result once it's done. Do not call request_progress right away — wait for the user's next message or do other work first.`,
},
],
});
}
const onProgress = createProgressForwarder(ctx, "generate_image");
return withSkillNotice(
await runAndMaterialize("generate_image", scratchpadPath, toolArgs.scratchpadFolder, preset, config.compactToolResult, renderArgs, async () => {
const { handleGenerateImage } = await import("../core/tools.js");
return runWithRequestCancel(ctx, () => handleGenerateImage(renderArgs, onProgress));
})
);
} catch (error) {
return await bridgeToolErrorResult("generate_image", error, logger, preset);
}
}
);
server.registerTool(
"request_progress",
{ description: requestProgressDescription(), inputSchema: fromJsonSchema(toMcpInputSchema(RequestProgressSchema, preset.scratchpadFolderRequired, preset) as any) },
async (args: unknown): Promise<any> => {
const toolArgs = args as ToolArgs & { requestId?: string; cancel?: boolean };
try {
const scratchpadPath = await resolveEffectiveScratchpadPath(config.chatWorkingDirectories, toolArgs.scratchpadFolder, preset.scratchpadFolderRequired, preset);
const requestId = String(toolArgs.requestId ?? "").trim();
if (!requestId && toolArgs.cancel === true) {
return { content: [{ type: "text", text: "request_progress failed: cancel requires a requestId." }], isError: true };
}
if (!requestId) {
const all = await listBackgroundRequests(scratchpadPath);
if (all.length === 0) return { content: [{ type: "text", text: "No background requests found for this scratchpad." }] };
const lines = all.map((r) => `- ${r.requestId} [${r.status === "running" && r.cancelRequested ? "cancelling" : r.status}]${r.status === "running" && r.message ? `: ${r.message}` : ""}`).join("\n");
const hint = all.some((r) => r.status === "completed") ? `\n${COMPLETED_LIST_HINT}` : "";
return { content: [{ type: "text", text: `${lines}${hint}` }] };
}
const scopeId = path.basename(scratchpadPath);
if (!requestBelongsToScope(requestId, scopeId)) {
return { content: [{ type: "text", text: `requestId "${requestId}" does not belong to this scratchpad (${scopeId}).` }], isError: true };
}
if (toolArgs.cancel === true) {
const res = await cancelBackgroundRequest(scratchpadPath, requestId);
if (res.outcome === "not-found") {
return { content: [{ type: "text", text: `No background request found for requestId "${requestId}". It may have expired or never existed.` }], isError: true };
}
if (res.outcome === "already-finished") {
return { content: [{ type: "text", text: `generate_image (requestId ${requestId}) is already ${res.status}; nothing to cancel.` }] };
}
if (res.outcome === "cancelled-queued") {
return { content: [{ type: "text", text: `generate_image (requestId ${requestId}) was removed from the queue and cancelled.` }] };
}
if (res.outcome === "cancelled") {
return { content: [{ type: "text", text: `generate_image (requestId ${requestId}) was cancelled: the job was aborted and has ended.` }] };
}
if (res.outcome === "finished-before-cancel") {
return { content: [{ type: "text", text: `generate_image (requestId ${requestId}) had already ${res.status} before the cancel took effect; nothing was cancelled. Use request_progress with this requestId to retrieve it.` }] };
}
return { content: [{ type: "text", text: `Cancellation of generate_image (requestId ${requestId}) was requested, but the job was still running after ${Math.round(res.waitedMs / 1000)} s; it is not confirmed as cancelled. Check again with request_progress.` }], isError: true };
}
const record = await getBackgroundRequest(scratchpadPath, requestId);
if (!record) {
return { content: [{ type: "text", text: `No background request found for requestId "${requestId}". It may have expired or never existed.` }], isError: true };
}
if (record.status === "cancelled") {
return { content: [{ type: "text", text: `generate_image (requestId ${requestId}) was cancelled.` }] };
}
if (record.status === "completed") {
await markBackgroundRequestDelivered(scratchpadPath, requestId);
return withSkillNotice(toToolResult(record.result));
}
if (record.status === "failed") {
await markBackgroundRequestDelivered(scratchpadPath, requestId);
return { content: [{ type: "text", text: `generate_image (requestId ${requestId}) failed: ${record.error || "unknown error"}` }], isError: true };
}
if (record.status === "running") {
const stepInfo =
record.message ||
(typeof record.step === "number" && typeof record.totalSteps === "number" && record.totalSteps > 0
? `step ${record.step}/${record.totalSteps}`
: "rendering");
return { content: [{ type: "text", text: `generate_image (requestId ${requestId}) is still running: ${stepInfo}. Do not call request_progress again immediately — wait for the user's next message or other work before checking back.` }] };
}
return { content: [{ type: "text", text: `generate_image (requestId ${requestId}) is queued and hasn't started rendering yet. Do not call request_progress again immediately — wait for the user's next message or other work before checking back.` }] };
} catch (error) {
return await bridgeToolErrorResult("request_progress", error, logger, preset);
}
}
);
server.registerTool(
"upscale",
{ description: upscaleDescription(preset, preset.scratchpadFolderRequired, effectiveUseSkillsForDescriptions), inputSchema: fromJsonSchema(toMcpInputSchema(UpscaleToolSchemaStrict, preset.scratchpadFolderRequired, preset) as any) },
async (args: unknown, ctx: unknown): Promise<any> => {
const toolArgs = args as ToolArgs;
try {
const scratchpadPath = await resolveEffectiveScratchpadPath(config.chatWorkingDirectories, toolArgs.scratchpadFolder, preset.scratchpadFolderRequired, preset);
await logger.logEvent("tool-request", { tool: "upscale", scratchpadPath });
// quality is hidden from the schema above; also strip it here so it can never reach the
// backend even if the caller sends it anyway — always resolves to the "auto" default.
const { scratchpadFolder: _scratchpadFolder, quality: _quality, ...renderArgsRaw } = toolArgs;
assertNoAttachmentNotation(renderArgsRaw, preset);
const renderArgs = renderArgsRaw;
const hookResult = await runBeforeToolCallHook(config, toolArgs, scratchpadPath, renderArgs);
if (hookResult.blocked) return { content: [{ type: "text", text: hookResult.message }] };
await preResolveCanvasField(renderArgs, "canvas", scratchpadPath);
bindChatContextToScratchpad(scratchpadPath);
const onProgress = createProgressForwarder(ctx, "upscale");
return withSkillNotice(
await runAndMaterialize("upscale", scratchpadPath, toolArgs.scratchpadFolder, preset, config.compactToolResult, renderArgs, async () => {
const { handleUpscale } = await import("../core/tools.js");
return runWithRequestCancel(ctx, () => handleUpscale(renderArgs, onProgress));
})
);
} catch (error) {
return await bridgeToolErrorResult("upscale", error, logger, preset);
}
}
);
},
});
}
main();
import crypto from "node:crypto";
import path from "node:path";
import { fromJsonSchema } from "@modelcontextprotocol/server";
import { zodToJsonSchema } from "zod-to-json-schema";
import { z } from "zod";
import {
bridgeToolErrorResult,
startStdioMcpServer,
looksLikeSourceNotation,
normalizeSourceFieldToArray,
resolveMcpSourceToken,
checkSkillStatus,
createSkillNoticeProvider,
createSkillFileUpdateNotice,
ensureLocalSkillCache,
syncSkillToInstallTarget,
createBackgroundRequestId,
runInBackgroundJob,
getBackgroundRequest,
markBackgroundRequestDelivered,
listBackgroundRequests,
cancelBackgroundRequest,
runWithCancelSignal,
requestBelongsToScope,
createMcpProgressSender,
createProgressReporter,
type ProgressCallback,
type McpResultPreset,
} from "./core-bundle.mjs";
import { GenerateToolParamsSchemaMinimalStrict, UpscaleToolSchemaStrict, getProjectRoot } from "../core-bundle.mjs";
import { applyMcpConfig, getRawEnvSnapshot, initCustomConfigs, readMcpConfig, type McpConfig } from "./config.js";
import { bindChatContextToScratchpad } from "./chatContextBridge.js";
import { assertNoAttachmentNotation, collectAttachmentTokens } from "./sourceNotation.js";
import { logger } from "./mcpLogger.js";
import { extractSummary, extractSummaryText, materializeRegisteredImages, writeHtmlReport, type RegisteredImage } from "./resultMaterializer.js";
import { resolveEffectiveScratchpadPath } from "./scratchpadResolution.js";
import { buildToolResult, buildUnslothToolResult, generateImageToolResult, generateSingleImageToolResult } from "./toolResults.js";
type ToolArgs = Record<string, unknown> & { scratchpadFolder?: string };
/**
* aN (LM-Studio attachment notation) doesn't exist over MCP for bionic/generic (see
* sourceNotation.ts) — but the shared `.describe()` text in draw-things-chat-core/schemas.ts
* still mentions it as an example, since that same text is also the LM-Studio plugin's own tool
* description (where aN IS valid). Rewriting schemas.ts would break the plugin's description, so
* this strips the aN mention only from the JSON schema text actually sent to MCP clients, leaving
* schemas.ts untouched.
*/
// bionic/generic: the shared canvas `.describe()` lists 'a1' as just another notation; merely
// stripping it would silently drop attachments. Replace the whole notation sentence with an
// explicit attachment→basename vs notation split before the generic 'a1' cleanups run.
const CANVAS_NOTATION_OLD = "Notation: 'a1', 'v2', 'p1', 'i3'.";
const CANVAS_NOTATION_NEW =
"An attachment the user added to the chat must be referenced by its filename (basename). Generated images, pictures and variants use notations 'i3', 'p1', 'v2'.";
function stripAttachmentNotationMentions(node: unknown): unknown {
if (Array.isArray(node)) return node.map(stripAttachmentNotationMentions);
if (node === null || typeof node !== "object") return node;
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
out[key] =
key === "description" && typeof value === "string"
? value
.split(CANVAS_NOTATION_OLD).join(CANVAS_NOTATION_NEW)
.replace(/'a1', /g, "")
.replace(/ \(e\.g\. 'a1'\)/g, "")
: stripAttachmentNotationMentions(value);
}
return out;
}
const ATTACHMENT_INVENTORY_HINT =
" Pass the literal \"aN\" (or any unregistered number) to get back a list of all attachments currently available in this thread instead of running the tool.";
/**
* unsloth is the one preset where aN genuinely resolves (see sourceNotation.ts) — instead of
* stripping the aN mention, append the inventory-listing hint right after it so the agent knows
* how to discover valid numbers.
*/
function annotateAttachmentNotationForUnsloth(node: unknown): unknown {
if (Array.isArray(node)) return node.map(annotateAttachmentNotationForUnsloth);
if (node === null || typeof node !== "object") return node;
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
out[key] = key === "description" && typeof value === "string" && value.includes("'a1'") ? `${value}${ATTACHMENT_INVENTORY_HINT}` : annotateAttachmentNotationForUnsloth(value);
}
return out;
}
/**
* Adds the MCP-only scratchpadFolder field on top of the plugin's real Zod schema — no field is
* hand-duplicated. Also hides `quality`, mirroring the plugin's own tools (generate_image.ts /
* upscale.ts), which always run at the backend's "auto" default and never expose it to the agent.
* scratchpadFolder's own requiredness comes from the resolved preset (made-for-bionic-core) —
* see presets/index.ts's doc comment for why this must not be re-derived locally.
*/
function toMcpInputSchema(zodSchema: z.ZodTypeAny, requireScratchpadFolder: boolean, preset: McpResultPreset): Record<string, unknown> {
const { $schema: _$schema, ...rawSchema } = zodToJsonSchema(zodSchema) as Record<string, any>;
const schema = (preset.name === "unsloth" ? annotateAttachmentNotationForUnsloth(rawSchema) : stripAttachmentNotationMentions(rawSchema)) as Record<string, any>;
const { quality: _quality, ...visibleProperties } = (schema.properties ?? {}) as Record<string, unknown>;
const baseRequired = ((schema.required as string[] | undefined) ?? []).filter((key) => key !== "quality");
// 'refine' is an internal generation profile applied via sourceTag (see core/tools.ts's
// isRefineProfile), not a mode an agent should pick directly — hide it from the exposed enum.
const mode = visibleProperties.mode as { enum?: string[] } | undefined;
if (mode?.enum) {
visibleProperties.mode = { ...mode, enum: mode.enum.filter((m) => m !== "refine") };
}
return {
...schema,
properties: {
scratchpadFolder: {
type: "string",
description: requireScratchpadFolder
? `Required scratchpad session folder name, one level under CHAT_WORKING_DIRECTORIES. ${scratchpadFolderGuidance(preset, true)}`
: "Optional scratchpad session folder name, one level under CHAT_WORKING_DIRECTORIES. Omit to write directly into CHAT_WORKING_DIRECTORIES.",
},
...visibleProperties,
},
required: requireScratchpadFolder ? ["scratchpadFolder", ...baseRequired] : baseRequired,
};
}
function sourceNotationGuidance(preset: McpResultPreset): string {
const base = "Sources (canvas/moodboard): pN (picture), iN (image, including a file this tool generated earlier), a filename in the scratchpad folder, or an absolute accessible path.";
if (preset.name !== "unsloth") return `${base} A file the user attached to the chat must be referenced by its filename (basename).`;
return `${base} Also accepts aN (attachment, e.g. a1).${ATTACHMENT_INVENTORY_HINT}`;
}
/**
* unsloth has no get_scratchpad_folder tool (see made-for-preset-rollout-plan.md) — its agent
* obtains the same value via \`cat .unsloth_sandbox\`. Reusing bionic's "get_scratchpad_folder"
* wording there would be actively wrong, not just imprecise. generic must never name a specific
* tool, not even speculatively ("if your host has X") — a generic/third-party MCP client can't be
* assumed to have any particular tool at all.
*/
function scratchpadFolderGuidance(preset: McpResultPreset, requireScratchpadFolder: boolean): string {
if (!requireScratchpadFolder) return "scratchpadFolder is optional — omit it to use the server's configured working directory, or pass an existing session folder name if your own setup already manages one.";
if (preset.name === "unsloth") return 'Before calling this tool: run `cat .unsloth_sandbox` and pass its output unchanged as scratchpadFolder — its output is an identifier such as "__LOCALID_htL7iV3", e.g. scratchpadFolder="__LOCALID_htL7iV3".';
return 'Before calling this tool: call get_scratchpad_folder, then pass its return value unchanged as scratchpadFolder — e.g. if get_scratchpad_folder returns "1a2b3c", call this tool with scratchpadFolder="1a2b3c".';
}
// Deliberately minimal — scratchpadFolder wrapping (toMcpInputSchema) is the only shared
// concern here, there's no canvas/model/notation surface to carry over from generate_image.
const RequestProgressSchema = z
.object({
requestId: z.string().optional().describe("The requestId returned by generate_image. Omit to list every background request for this scratchpad."),
cancel: z.boolean().optional().describe("If true, cancels the request given by requestId instead of checking it. Queued requests are removed from the queue, running ones are aborted."),
})
.strict();
/**
* Full model-preset list for the MCP generate_image schema. The MCP tool description stays brief
* (one example); this authoritative list lives in the schema so the agent never guesses a preset.
* Keep in sync with the plugin's src/tools/generate_image.ts model line and draw-things-chat-core
* capabilities.ts — see model-preset-registration-checklist.md.
*/
const GENERATE_MODEL_FIELD_DESCRIPTION =
"Model preset. One of: auto (default), z-image, krea, qwen-image, qwen-image-2.1, qwen-image-2.1-turbo, flux, ltx, minimax, minimax-ref, custom, or a user-defined entry from custom_configs.json. 'auto' selects the best model for the mode. Video modes (text2video/image2video) require a video-capable model: ltx, minimax, or minimax-ref.";
// Stage 1 of the SKILL.md migration (see made-for-bionic/instructions/agent_tools/
// tool-description-skill-concept.md): useSkills=true keeps the tool-level description minimal,
// routing to the installed skill; full parameter semantics then live in
// skills/generate-image/tools/<tool>.md. useSkills=false (e.g. no skill installed for this
// client) restores the original, fully inline description. Field descriptions
// (scratchpadFolderGuidance, sourceNotation, model preset list, ...) are untouched either way.
function generateImageDescription(preset: McpResultPreset, requireScratchpadFolder: boolean, useSkills: boolean): string {
if (useSkills) {
return "Generate an image or video using Draw Things. Before calling: read skill 'generate-image' (SKILL.md for an overview, tools/generate_image.md for modes, model presets, and source notation).";
}
return `Generate an image or video using Draw Things.
${scratchpadFolderGuidance(preset, requireScratchpadFolder)}
${sourceNotationGuidance(preset)}
Pick a model with the \`model\` parameter (e.g. \`qwen-image-2.1\`); the full preset list is in its schema. Omit it to let \`auto\` choose.`;
}
function upscaleDescription(preset: McpResultPreset, requireScratchpadFolder: boolean, useSkills: boolean): string {
if (useSkills) {
return "Re-render the canvas at a higher resolution via Draw Things image2image. No crop — uses the full canvas as-is. Before calling: read skill 'generate-image' (SKILL.md for an overview, tools/upscale.md for parameter details).";
}
return `Re-render the canvas at a higher resolution via Draw Things image2image. No crop — uses the full canvas as-is.
${scratchpadFolderGuidance(preset, requireScratchpadFolder)}
${sourceNotationGuidance(preset)}`;
}
function requestProgressDescription(): string {
return "Check, retrieve or cancel generate_image renders started with runInBackground: true. Normal use: pass requestId (the id generate_image returned) — while the render runs this shows its progress, once it has finished it returns the full result including the image, which is the only way to receive a background image. To CANCEL a request (queued or running), pass requestId and cancel: true. Without requestId the tool only lists the status of all requests for this scratchpad (no images); use that just to recover a lost requestId. Do not call this again immediately after a non-completed result — wait for the user's next message or other work first.";
}
const COMPLETED_LIST_HINT = "This list shows statuses only. To evaluate a finished (completed) image, call request_progress with its requestId.";
// The MCP SDK's CallToolResult content-block union isn't re-exported for reuse here,
// so the handler result stays structurally typed (any) rather than hand-duplicating it.
function toToolResult(result: unknown): any {
const record = result as { content?: unknown; isError?: unknown } | undefined;
if (record && Array.isArray(record.content)) {
return record.isError === true ? { content: record.content, isError: true } : { content: record.content };
}
return { content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result) }] };
}
/**
* Runs a foreground render so that cancelling the MCP request (notifications/cancelled → ctx.mcpReq.signal)
* closes the gRPC stream instead of letting the render run to completion.
*/
function runWithRequestCancel<T>(ctx: unknown, fn: () => Promise<T>): Promise<T> {
const signal = (ctx as { mcpReq?: { signal?: AbortSignal } } | undefined)?.mcpReq?.signal;
return signal ? runWithCancelSignal(signal, fn) : fn();
}
/**
* Forwards handleGenerateImage/handleUpscale's ProgressCallback to MCP notifications/progress
* (same wording and ETA as the LM Studio plugin's ctx.status, both from made-for-bionic-core's
* progressReporter). undefined unless the client sent a progressToken.
*/
function createProgressForwarder(ctx: unknown, tool: string): ProgressCallback | undefined {
const send = createMcpProgressSender(ctx, (error) => {
logger.logError("progress-notify-failed", error, { tool }).catch(() => {});
});
return send ? createProgressReporter(send) : undefined;
}
/**
* Only the unsloth preset defines beforeToolCall (see made-for-bionic-core's presets/unsloth.ts)
* — it re-syncs newly discovered attachments into chat_media_state.json and validates any aN
* token this call referenced. `threadId` is the raw scratchpadFolder value the client passed:
* for unsloth that IS the chat_threads.id (see `cat .unsloth_sandbox`), not a resolved path.
*/
async function runBeforeToolCallHook(config: McpConfig, toolArgs: ToolArgs, scratchpadPath: string, renderArgs: Record<string, unknown>): Promise<{ blocked: true; message: string } | { blocked: false }> {
if (!config.preset.beforeToolCall) return { blocked: false };
return config.preset.beforeToolCall({
scratchpadPath,
threadId: toolArgs.scratchpadFolder ?? "",
clientDbPath: config.clientDbLocation,
attachmentTokens: collectAttachmentTokens(renderArgs),
});
}
/**
* Resolves a basename/absolute-path/preview-filename `canvas`/`moodboard` value (the 3
* non-aN/vN/iN/pN MCP input shapes — see made-for-bionic-core's resolveMcpSourceToken()) into a
* containment-checked absolute path to the ORIGINAL file, BEFORE core/tools.ts's handler ever sees
* it. aN/vN/iN/pN values are left untouched for the handler's own notation parser — including a
* real `aN` for unsloth: core/tools.ts's detectLabel-driven crop logic and cropSource/detectSource
* redirects match the raw `canvas` string against stored notations verbatim, so rewriting `aN` to
* a path here would silently break that re-matching. The `attachment` pool's own LM-Studio-only
* resolver is instead fixed locally inside core/tools.ts (see resolveAttachmentFromState()).
* Mutates `renderArgs` in place.
*/
async function preResolveCanvasField(renderArgs: Record<string, unknown>, field: string, scratchpadPath: string): Promise<void> {
const raw = renderArgs[field];
if (raw === undefined) return;
const wasArray = Array.isArray(raw);
const tokens = normalizeSourceFieldToArray(raw);
if (tokens.length === 0) return;
const resolved: string[] = [];
for (const token of tokens) {
if (looksLikeSourceNotation(token)) {
resolved.push(token);
continue;
}
const abs = await resolveMcpSourceToken(token, scratchpadPath);
resolved.push(abs ?? token);
}
renderArgs[field] = wasArray || resolved.length > 1 ? resolved : resolved[0];
}
/**
* Runs a render call, takes the iN entries it registered from its own result
* (`registeredImages`, see resultMaterializer.ts) and turns them into a tool result, per the resolved
* preset (made-for-bionic-core's presets/{bionic,generic}.ts — the single source of truth for
* base64/HTML-report/scratchpadFolder behavior, shared by every Bionic MCP bridge):
* - includeBase64Preview: the generic, client-agnostic shape (buildToolResult) — inline base64
* preview(s), http/file links, and a generic usage hint, no host-specific tool references.
* compactToolResult (COMPACT_TOOL_RESULT env var) drops the http/file links and the
* "show this to the user" sentence, merging a presented-already sentence into the usage hint.
* - otherwise: Bionic-guided tool-chaining text (open_url_in_app_browser -> view_images ->
* attach_file) — an HTML report for 2+ results (generateImageToolResult, gated by
* writeHtmlReportForMultipleResults) or the file's own preview inlined for exactly 1 result
* (generateSingleImageToolResult). compactToolResult has no effect here.
* Falls back to the renderer's raw content when nothing new was recorded (e.g. an early
* validation error inside handleGenerateImage/handleUpscale itself).
*/
async function runAndMaterialize(
tool: "generate_image" | "upscale",
scratchpadPath: string,
scratchpadFolder: string | undefined,
preset: McpResultPreset,
compactToolResult: boolean,
renderArgs: Record<string, unknown>,
render: () => Promise<unknown>
): Promise<any> {
const result = await render();
const record = result as { content?: unknown; isError?: unknown; registeredImages?: RegisteredImage[] } | undefined;
if (record?.isError === true) return toToolResult(result);
const materialized = await materializeRegisteredImages(scratchpadPath, record?.registeredImages ?? []);
if (materialized.length === 0) return toToolResult(result);
if (preset.includeBase64Preview) {
const summaryText = extractSummaryText(result);
await logger.logEvent("tool-materialized", { tool, scratchpadPath, notations: materialized.map((m) => m.notation).join(",") });
if (preset.name === "unsloth") {
return buildUnslothToolResult(tool, scratchpadPath, scratchpadFolder, materialized, summaryText, compactToolResult);
}
return buildToolResult(tool, scratchpadPath, scratchpadFolder, materialized, summaryText, true, compactToolResult);
}
// Single-result case: no HTML report — the in-app browser opens the file's own preview, and
// metadata is inlined directly from the tool call's own summary (see toolResults.ts).
if (materialized.length === 1 || !preset.writeHtmlReportForMultipleResults) {
const summary = extractSummary(result);
await logger.logEvent("tool-materialized", { tool, scratchpadPath, notations: materialized[0]!.notation });
return { content: [{ type: "text", text: generateSingleImageToolResult(tool, materialized[0]!, summary) }] };
}
// Named after the tool's own audit requestId (see core/tools.ts, generate-image-plugin.audit.jsonl)
// so the report file can be matched back to its audit log entry. Falls back to a short random id
// in the unlikely case the summary doesn't carry one.
const summary = extractSummary(result);
const requestId = typeof summary?.requestId === "string" && summary.requestId.trim() ? summary.requestId : crypto.randomUUID().slice(0, 8);
const reportPath = await writeHtmlReport(
scratchpadPath,
requestId,
{
tool,
prompt: typeof renderArgs.prompt === "string" ? renderArgs.prompt : undefined,
canvas: typeof renderArgs.canvas === "string" ? renderArgs.canvas : undefined,
summary,
},
materialized
);
await logger.logEvent("tool-materialized", { tool, scratchpadPath, notations: materialized.map((m) => m.notation).join(","), reportPath });
return { content: [{ type: "text", text: generateImageToolResult(tool, reportPath, materialized) }] };
}
async function main(): Promise<void> {
// Logged BEFORE readMcpConfig() (which throws on a missing required var) so a full,
// unfiltered record of what Bionic actually passed in always reaches generate-image-mcp.log —
// every var, whether explicitly set, defaulted, or unset/empty.
await logger.logEvent("env snapshot (raw)", getRawEnvSnapshot());
const config = readMcpConfig();
applyMcpConfig(config);
const customConfigsOutcome = await initCustomConfigs(config.customConfigsPath);
await logger.logEvent("Custom Configs", { ...customConfigsOutcome });
// Single source of truth (made-for-bionic-core) for base64/HTML-report/scratchpadFolder
// behavior — computed once in config.ts and threaded through, never re-derived below.
const preset = config.preset;
await logger.logEvent("MCP server starting", {
drawThingsHost: config.drawThingsHost,
drawThingsHttpPort: config.drawThingsHttpPort,
drawThingsGrpcPort: config.drawThingsGrpcPort,
preset: preset.name,
presetIncludeBase64Preview: preset.includeBase64Preview,
presetWriteHtmlReportForMultipleResults: preset.writeHtmlReportForMultipleResults,
presetScratchpadFolderRequired: preset.scratchpadFolderRequired,
embedPngMetadata: config.embedPngMetadata,
httpServerPort: config.httpServerPort,
chatWorkingDirectories: config.chatWorkingDirectories,
useSkills: config.useSkills,
autoUpdateSkills: config.autoUpdateSkills,
nodeVersion: process.version,
});
// Computed once per server run (one run ~= one client session) rather than per tool call —
// see tool-description-skill-concept.md's "USE_SKILLS-Gate" section for why this must never be
// mentioned unconditionally inside SKILL.md itself (that would make every agent re-check on
// every read instead of being told, at most once, exactly when it matters). 'image-display' is
// checked the same way as 'generate-image' itself — a passive "## Related skills" mention in
// SKILL.md isn't a reliable enough link, since nothing forces an agent to read that section
// before displaying a result (see tool-description-skill-concept.md's "Architekturentscheidung 5").
// Step 1 (local cache first, see tool-description-skill-concept.md): keep skills/<name>/ in
// this plugin's own install directory current from the public GitHub mirror, but only hit
// GitHub when the cache doesn't match this plugin build's own version — never on every session.
const projectRoot = getProjectRoot();
if (config.autoUpdateSkills) {
const cacheResult = await ensureLocalSkillCache("generate-image", { projectRoot });
await logger.logEvent("skill-cache", { skill: "generate-image", result: cacheResult });
const imageDisplayCacheResult = await ensureLocalSkillCache("image-display", { projectRoot });
await logger.logEvent("skill-cache", { skill: "image-display", result: imageDisplayCacheResult });
}
// Step 2: compare the client-installed copy against that (now-fresh) local cache.
const skillStatus = checkSkillStatus(preset, config.useSkills, "generate-image", "generate-image", { projectRoot });
const imageDisplaySkillStatus = checkSkillStatus(preset, config.useSkills, "image-display", "generate-image", { projectRoot });
await logger.logEvent("skill-status", { skill: "generate-image", ...skillStatus });
await logger.logEvent("skill-status", { skill: "image-display", ...imageDisplaySkillStatus });
// Step 3: propagate to the install target, per file — never when the user has edited that
// specific file (userModified), and never at all when AUTO_UPDATE_SKILLS=false (manual install
// scripts only). Runs unconditionally (not gated on skillStatus.state) so a file whose own
// content changed (e.g. models/qwen-image.md) still gets synced even when SKILL.md itself is
// unchanged — see made-for-bionic-core's syncSkillToInstallTarget() for the per-file logic.
let effectiveSkillStatus: typeof skillStatus = skillStatus;
let skillFileUpdateNotice: string | undefined;
if (config.useSkills && config.autoUpdateSkills) {
const syncResult = syncSkillToInstallTarget("generate-image", preset, { projectRoot });
await logger.logEvent("skill-sync", { skill: "generate-image", status: syncResult.status, updatedFiles: syncResult.updatedFiles.join(","), userModifiedFiles: syncResult.userModifiedFiles.join(",") });
if (syncResult.status !== "failed" && (skillStatus.state === "not-installed" || (skillStatus.state === "stale" && !skillStatus.userModified))) {
effectiveSkillStatus = { state: "current" };
}
skillFileUpdateNotice = createSkillFileUpdateNotice("generate-image", projectRoot, syncResult.userModifiedFiles);
}
let effectiveImageDisplaySkillStatus: typeof imageDisplaySkillStatus = imageDisplaySkillStatus;
let imageDisplayFileUpdateNotice: string | undefined;
if (config.useSkills && config.autoUpdateSkills) {
const syncResult = syncSkillToInstallTarget("image-display", preset, { projectRoot });
await logger.logEvent("skill-sync", { skill: "image-display", status: syncResult.status, updatedFiles: syncResult.updatedFiles.join(","), userModifiedFiles: syncResult.userModifiedFiles.join(",") });
if (syncResult.status !== "failed" && (imageDisplaySkillStatus.state === "not-installed" || (imageDisplaySkillStatus.state === "stale" && !imageDisplaySkillStatus.userModified))) {
effectiveImageDisplaySkillStatus = { state: "current" };
}
imageDisplayFileUpdateNotice = createSkillFileUpdateNotice("image-display", projectRoot, syncResult.userModifiedFiles);
}
// Point 1: if the skill still isn't actually available/current (sync failed, or the user's own
// edited copy is being respected), fall back to the full inline tool description instead of a
// dangling pointer at a skill the agent may not be able to read usefully.
const effectiveUseSkillsForDescriptions = config.useSkills && effectiveSkillStatus.state === "current";
const getSkillNotice = createSkillNoticeProvider(effectiveSkillStatus, "generate-image");
const getImageDisplaySkillNotice = createSkillNoticeProvider(effectiveImageDisplaySkillStatus, "image-display");
let skillFileNoticesShown = false;
function getSkillFileNotices(): string[] {
if (skillFileNoticesShown) return [];
skillFileNoticesShown = true;
return [skillFileUpdateNotice, imageDisplayFileUpdateNotice].filter((notice): notice is string => Boolean(notice));
}
function withSkillNotice(result: any): any {
const notices = [getSkillNotice(), getImageDisplaySkillNotice(), ...getSkillFileNotices()].filter((notice): notice is string => Boolean(notice));
if (notices.length === 0 || !result || !Array.isArray(result.content)) return result;
return { ...result, content: [...result.content, ...notices.map((text) => ({ type: "text", text }))] };
}
startStdioMcpServer({
name: "generate-image-mcp",
version: "0.1.0",
buildServer: (server) => {
const generateInputSchema = toMcpInputSchema(GenerateToolParamsSchemaMinimalStrict, preset.scratchpadFolderRequired, preset);
const generateModelProp = (generateInputSchema.properties as Record<string, any>).model as Record<string, unknown> | undefined;
if (generateModelProp) {
(generateInputSchema.properties as Record<string, any>).model = { ...generateModelProp, description: GENERATE_MODEL_FIELD_DESCRIPTION };
}
server.registerTool(
"generate_image",
{ description: generateImageDescription(preset, preset.scratchpadFolderRequired, effectiveUseSkillsForDescriptions), inputSchema: fromJsonSchema(generateInputSchema as any) },
async (args: unknown, ctx: unknown): Promise<any> => {
const toolArgs = args as ToolArgs & { runInBackground?: boolean };
try {
const scratchpadPath = await resolveEffectiveScratchpadPath(config.chatWorkingDirectories, toolArgs.scratchpadFolder, preset.scratchpadFolderRequired, preset);
await logger.logEvent("tool-request", { tool: "generate_image", scratchpadPath });
// quality is hidden from the schema above; also strip it here so it can never reach the
// backend even if the caller sends it anyway — always resolves to the "auto" default.
// runInBackground is a transport-level flag handled right here, never a render param.
const { scratchpadFolder: _scratchpadFolder, quality: _quality, runInBackground, ...renderArgsRaw } = toolArgs;
assertNoAttachmentNotation(renderArgsRaw, preset);
const renderArgs = renderArgsRaw;
const hookResult = await runBeforeToolCallHook(config, toolArgs, scratchpadPath, renderArgs);
if (hookResult.blocked) return { content: [{ type: "text", text: hookResult.message }] };
await preResolveCanvasField(renderArgs, "canvas", scratchpadPath);
await preResolveCanvasField(renderArgs, "moodboard", scratchpadPath);
bindChatContextToScratchpad(scratchpadPath);
if (runInBackground === true) {
const requestId = createBackgroundRequestId(path.basename(scratchpadPath));
// Awaited: guarantees the record exists on disk before this tool call returns, so an
// immediate request_progress call in the same turn can never race ahead of it.
await runInBackgroundJob({
scratchpadPath,
requestId,
tool: "generate_image",
run: (bgOnProgress) =>
runAndMaterialize("generate_image", scratchpadPath, toolArgs.scratchpadFolder, preset, config.compactToolResult, renderArgs, async () => {
const { handleGenerateImage } = await import("../core/tools.js");
return handleGenerateImage(renderArgs, bgOnProgress, { requestIdOverride: requestId });
}),
});
return withSkillNotice({
content: [
{
type: "text",
text: `Generation started in the background (requestId: ${requestId}). Use request_progress with this requestId to check status or retrieve the result once it's done. Do not call request_progress right away — wait for the user's next message or do other work first.`,
},
],
});
}
const onProgress = createProgressForwarder(ctx, "generate_image");
return withSkillNotice(
await runAndMaterialize("generate_image", scratchpadPath, toolArgs.scratchpadFolder, preset, config.compactToolResult, renderArgs, async () => {
const { handleGenerateImage } = await import("../core/tools.js");
return runWithRequestCancel(ctx, () => handleGenerateImage(renderArgs, onProgress));
})
);
} catch (error) {
return await bridgeToolErrorResult("generate_image", error, logger, preset);
}
}
);
server.registerTool(
"request_progress",
{ description: requestProgressDescription(), inputSchema: fromJsonSchema(toMcpInputSchema(RequestProgressSchema, preset.scratchpadFolderRequired, preset) as any) },
async (args: unknown): Promise<any> => {
const toolArgs = args as ToolArgs & { requestId?: string; cancel?: boolean };
try {
const scratchpadPath = await resolveEffectiveScratchpadPath(config.chatWorkingDirectories, toolArgs.scratchpadFolder, preset.scratchpadFolderRequired, preset);
const requestId = String(toolArgs.requestId ?? "").trim();
if (!requestId && toolArgs.cancel === true) {
return { content: [{ type: "text", text: "request_progress failed: cancel requires a requestId." }], isError: true };
}
if (!requestId) {
const all = await listBackgroundRequests(scratchpadPath);
if (all.length === 0) return { content: [{ type: "text", text: "No background requests found for this scratchpad." }] };
const lines = all.map((r) => `- ${r.requestId} [${r.status === "running" && r.cancelRequested ? "cancelling" : r.status}]${r.status === "running" && r.message ? `: ${r.message}` : ""}`).join("\n");
const hint = all.some((r) => r.status === "completed") ? `\n${COMPLETED_LIST_HINT}` : "";
return { content: [{ type: "text", text: `${lines}${hint}` }] };
}
const scopeId = path.basename(scratchpadPath);
if (!requestBelongsToScope(requestId, scopeId)) {
return { content: [{ type: "text", text: `requestId "${requestId}" does not belong to this scratchpad (${scopeId}).` }], isError: true };
}
if (toolArgs.cancel === true) {
const res = await cancelBackgroundRequest(scratchpadPath, requestId);
if (res.outcome === "not-found") {
return { content: [{ type: "text", text: `No background request found for requestId "${requestId}". It may have expired or never existed.` }], isError: true };
}
if (res.outcome === "already-finished") {
return { content: [{ type: "text", text: `generate_image (requestId ${requestId}) is already ${res.status}; nothing to cancel.` }] };
}
if (res.outcome === "cancelled-queued") {
return { content: [{ type: "text", text: `generate_image (requestId ${requestId}) was removed from the queue and cancelled.` }] };
}
if (res.outcome === "cancelled") {
return { content: [{ type: "text", text: `generate_image (requestId ${requestId}) was cancelled: the job was aborted and has ended.` }] };
}
if (res.outcome === "finished-before-cancel") {
return { content: [{ type: "text", text: `generate_image (requestId ${requestId}) had already ${res.status} before the cancel took effect; nothing was cancelled. Use request_progress with this requestId to retrieve it.` }] };
}
return { content: [{ type: "text", text: `Cancellation of generate_image (requestId ${requestId}) was requested, but the job was still running after ${Math.round(res.waitedMs / 1000)} s; it is not confirmed as cancelled. Check again with request_progress.` }], isError: true };
}
const record = await getBackgroundRequest(scratchpadPath, requestId);
if (!record) {
return { content: [{ type: "text", text: `No background request found for requestId "${requestId}". It may have expired or never existed.` }], isError: true };
}
if (record.status === "cancelled") {
return { content: [{ type: "text", text: `generate_image (requestId ${requestId}) was cancelled.` }] };
}
if (record.status === "completed") {
await markBackgroundRequestDelivered(scratchpadPath, requestId);
return withSkillNotice(toToolResult(record.result));
}
if (record.status === "failed") {
await markBackgroundRequestDelivered(scratchpadPath, requestId);
return { content: [{ type: "text", text: `generate_image (requestId ${requestId}) failed: ${record.error || "unknown error"}` }], isError: true };
}
if (record.status === "running") {
const stepInfo =
record.message ||
(typeof record.step === "number" && typeof record.totalSteps === "number" && record.totalSteps > 0
? `step ${record.step}/${record.totalSteps}`
: "rendering");
return { content: [{ type: "text", text: `generate_image (requestId ${requestId}) is still running: ${stepInfo}. Do not call request_progress again immediately — wait for the user's next message or other work before checking back.` }] };
}
return { content: [{ type: "text", text: `generate_image (requestId ${requestId}) is queued and hasn't started rendering yet. Do not call request_progress again immediately — wait for the user's next message or other work before checking back.` }] };
} catch (error) {
return await bridgeToolErrorResult("request_progress", error, logger, preset);
}
}
);
server.registerTool(
"upscale",
{ description: upscaleDescription(preset, preset.scratchpadFolderRequired, effectiveUseSkillsForDescriptions), inputSchema: fromJsonSchema(toMcpInputSchema(UpscaleToolSchemaStrict, preset.scratchpadFolderRequired, preset) as any) },
async (args: unknown, ctx: unknown): Promise<any> => {
const toolArgs = args as ToolArgs;
try {
const scratchpadPath = await resolveEffectiveScratchpadPath(config.chatWorkingDirectories, toolArgs.scratchpadFolder, preset.scratchpadFolderRequired, preset);
await logger.logEvent("tool-request", { tool: "upscale", scratchpadPath });
// quality is hidden from the schema above; also strip it here so it can never reach the
// backend even if the caller sends it anyway — always resolves to the "auto" default.
const { scratchpadFolder: _scratchpadFolder, quality: _quality, ...renderArgsRaw } = toolArgs;
assertNoAttachmentNotation(renderArgsRaw, preset);
const renderArgs = renderArgsRaw;
const hookResult = await runBeforeToolCallHook(config, toolArgs, scratchpadPath, renderArgs);
if (hookResult.blocked) return { content: [{ type: "text", text: hookResult.message }] };
await preResolveCanvasField(renderArgs, "canvas", scratchpadPath);
bindChatContextToScratchpad(scratchpadPath);
const onProgress = createProgressForwarder(ctx, "upscale");
return withSkillNotice(
await runAndMaterialize("upscale", scratchpadPath, toolArgs.scratchpadFolder, preset, config.compactToolResult, renderArgs, async () => {
const { handleUpscale } = await import("../core/tools.js");
return runWithRequestCancel(ctx, () => handleUpscale(renderArgs, onProgress));
})
);
} catch (error) {
return await bridgeToolErrorResult("upscale", error, logger, preset);
}
}
);
},
});
}
main();