src / mcp / index.ts
src / mcp / index.ts
import crypto from "node:crypto";
import { fromJsonSchema } from "@modelcontextprotocol/server";
import { zodToJsonSchema } from "zod-to-json-schema";
import type { z } from "zod";
import {
bridgeToolErrorResult,
startStdioMcpServer,
looksLikeSourceNotation,
normalizeSourceFieldToArray,
resolveMcpSourceToken,
checkSkillStatus,
createSkillNoticeProvider,
createSkillFileUpdateNotice,
ensureLocalSkillCache,
syncSkillToInstallTarget,
createMcpProgressSender,
createProgressReporter,
type ProgressCallback,
type McpResultPreset,
type ResultTier,
} from "./core-bundle.mjs";
import { CropToolSchemaStrict, InpaintToolSchemaStrict, OutpaintToolSchemaStrict, ZoomInToolSchemaStrict, 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, materializeNewImages, snapshotImageIndices, writeHtmlReport } from "./resultMaterializer.js";
import { resolveEffectiveScratchpadPath } from "./scratchpadResolution.js";
import { buildGenericToolResult, buildMultiResultText, buildSingleResultText, buildUnslothToolResult } from "./toolResults.js";
type ToolName = "crop" | "mask" | "zoom-in" | "inpaint" | "outpaint";
type ToolArgs = Record<string, unknown> & { scratchpadFolder?: string };
/**
* 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/inpaint/outpaint logic matches
* the raw `canvas` string against stored `detectSource`/`cropSource` 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 each plugin's 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];
}
/**
* zod-to-json-schema collapses a union of only-primitive types (e.g. cropLeft/cropRight/cropTop/
* cropBottom/frameAdjust's `z.union([z.number(), z.string()])`) into a single node with
* `type: ["number", "string"]` instead of `anyOf` branches. Several MCP clients only accept
* `type` as a single string and either reject the tool or silently drop the constraint on such a
* node. Recursively rewrites every such node into `{ anyOf: [{ type: "number" }, { type: "string" }], ...rest }`,
* walking into properties/items/anyOf/allOf/oneOf/additionalProperties so nested cases (e.g. an
* array's items) are fixed too.
*/
function splitTypeArrayUnions(node: unknown): unknown {
if (Array.isArray(node)) return node.map(splitTypeArrayUnions);
if (node === null || typeof node !== "object") return node;
const { type, properties, items, anyOf, allOf, oneOf, additionalProperties, ...rest } = node as Record<string, unknown>;
const walked: Record<string, unknown> = { ...rest };
if (properties && typeof properties === "object") {
walked.properties = Object.fromEntries(Object.entries(properties as Record<string, unknown>).map(([key, value]) => [key, splitTypeArrayUnions(value)]));
}
if (items !== undefined) walked.items = splitTypeArrayUnions(items);
if (additionalProperties !== undefined) walked.additionalProperties = splitTypeArrayUnions(additionalProperties);
if (anyOf !== undefined) walked.anyOf = (anyOf as unknown[]).map(splitTypeArrayUnions);
if (allOf !== undefined) walked.allOf = (allOf as unknown[]).map(splitTypeArrayUnions);
if (oneOf !== undefined) walked.oneOf = (oneOf as unknown[]).map(splitTypeArrayUnions);
if (Array.isArray(type)) {
walked.anyOf = [...(Array.isArray(walked.anyOf) ? (walked.anyOf as unknown[]) : []), ...type.map((t) => ({ type: t }))];
} else if (type !== undefined) {
walked.type = type;
}
return walked;
}
/**
* Only canvas/moodboard fields actually accept aN (see sourceNotation.ts) — unsloth's own
* attachment-inventory hint (see ATTACHMENT_INVENTORY_HINT below) must only ever be appended to
* those, not to a field that merely MENTIONS 'a1' as an illustrative example of a canvas value
* (e.g. detectLabel's own "canvas may be the original source (e.g. 'a1') ..." prose) — appending
* it there would be nonsensical (detectLabel isn't a source/attachment field).
*/
const SOURCE_NOTATION_FIELD_NAMES = new Set(["canvas", "moodboard"]);
/**
* 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 everywhere canvas notation comes up (not just on the canvas
* field itself, e.g. detectLabel's own "(e.g. 'a1')" aside), 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 every 'a1' mention from the JSON schema text actually
* sent to bionic/generic MCP clients, leaving schemas.ts untouched. Applies to ANY description,
* not just canvas/moodboard — bionic/generic must never see an aN example anywhere.
*/
// 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((item) => stripAttachmentNotationMentions(item));
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. Scoped to canvas/moodboard fields only — see
* SOURCE_NOTATION_FIELD_NAMES's doc comment above.
*/
function annotateAttachmentNotationForUnsloth(node: unknown, parentKey?: string): unknown {
if (Array.isArray(node)) return node.map((item) => annotateAttachmentNotationForUnsloth(item, parentKey));
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>)) {
if (key === "description" && typeof value === "string") {
out[key] = parentKey && SOURCE_NOTATION_FIELD_NAMES.has(parentKey) && value.includes("'a1'") ? `${value}${ATTACHMENT_INVENTORY_HINT}` : value;
} else {
out[key] = annotateAttachmentNotationForUnsloth(value, key);
}
}
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` (a no-op for crop/mask, whose schemas have no such field),
* mirroring the plugin's own tools, 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. `extraHiddenFields` additionally hides per-tool fields (e.g. zoom-in's `model`, which
* has exactly one valid value until a second zoom-capable model exists — see register()'s call).
*/
function toMcpInputSchema(zodSchema: z.ZodTypeAny, requireScratchpadFolder: boolean, preset: McpResultPreset, extraHiddenFields: readonly string[] = []): Record<string, unknown> {
const { $schema: _$schema, ...rawSchema } = zodToJsonSchema(zodSchema) as Record<string, any>;
const schema = (preset.name === "unsloth" ? annotateAttachmentNotationForUnsloth(splitTypeArrayUnions(rawSchema)) : stripAttachmentNotationMentions(splitTypeArrayUnions(rawSchema))) as Record<string, any>;
const hiddenFields = new Set<string>(["quality", ...extraHiddenFields]);
const visibleProperties = Object.fromEntries(Object.entries((schema.properties ?? {}) as Record<string, unknown>).filter(([key]) => !hiddenFields.has(key)));
const baseRequired = ((schema.required as string[] | undefined) ?? []).filter((key) => !hiddenFields.has(key));
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,
};
}
const SOURCE_NOTATION_GUIDANCE =
"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.";
function sourceNotationGuidance(preset: McpResultPreset): string {
if (preset.name !== "unsloth") return `${SOURCE_NOTATION_GUIDANCE} A file the user attached to the chat must be referenced by its filename (basename).`;
return `${SOURCE_NOTATION_GUIDANCE} 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".';
}
/** aN is only ever valid canvas notation for the unsloth preset (see sourceNotation.ts) — bionic/generic must never see it mentioned, not even as an example. */
function canvasNotationList(preset: McpResultPreset): string {
return preset.name === "unsloth" ? "a1, v2, p1, i3" : "v2, p1, i3";
}
/** Same rule as canvasNotationList() — see its doc comment. */
function canvasOrDetectResultExample(preset: McpResultPreset): string {
return preset.name === "unsloth"
? "canvas may be the original source (e.g. a1) or the annotated detect_object result (e.g. i3)."
: "canvas may be the original source or the annotated detect_object result (e.g. i3).";
}
function cropDescription(preset: McpResultPreset, requireScratchpadFolder: boolean, useSkills: boolean): string {
if (useSkills) {
return "Crop an image by adjusting one axis at a time. Before calling: read skill 'process-image' (SKILL.md for an overview, tools/crop.md for the iterative crop procedure and detect_object integration).";
}
return `Crop an image by adjusting one axis at a time.
NOTE — If you have access to detect_object: Use detectLabel to crop directly to a detected object's bounding box.
- detectLabel: label text to match (e.g. cat, a person) — case-insensitive substring match.
- detectIndex: zero-based index when multiple matches exist (default: 0).
- frameAdjust: expand (+) or shrink (-) the box. Number = % of bbox dimensions (preserves AR); string with 'px' suffix = absolute pixel margin. E.g. 5, -3, 20px.
${canvasOrDetectResultExample(preset)}
Example: canvas=i3, detectLabel=cat, frameAdjust=5
IMPORTANT — Without detect_object (iterative procedure, do NOT set all sides at once):
1. Set cropLeft only → inspect result.
2. Keep cropLeft, set cropRight only → inspect.
3. Keep both, set cropTop only → inspect.
4. Set imageFormat to lock the aspect ratio (derives cropBottom automatically).
At tight crops, 1–3% per step is enough. Symmetric values do NOT guarantee a centred subject — judge by visual gap, not numbers.
Parameters:
- canvas: Source image. Notation: ${canvasNotationList(preset)}.
- cropLeft / cropRight / cropTop / cropBottom: Amount to remove per edge. Default unit: % (0–99). Optionally append px for absolute pixels (e.g. 120px or 10%).
- imageFormat: Target aspect ratio (square=1:1, landscape=4:3, portrait=3:4, 16:9).
Only active when explicitly set. Ignored when all 4 sides are given.
Per axis: neither side → centred; one side → anchored, opposite defaults to 0; both → locked.
Examples:
imageFormat=portrait → centred 3:4 crop
cropLeft=10, imageFormat=16:9 → anchor left at 10%, centre vertically
cropLeft=15, cropRight=15, cropTop=10, cropBottom=10 → all 4 explicit, imageFormat ignored
Returns: The cropped image saved to the chat working directory as a new variant.
${scratchpadFolderGuidance(preset, requireScratchpadFolder)}
${sourceNotationGuidance(preset)}`;
}
function maskDescription(preset: McpResultPreset, requireScratchpadFolder: boolean, useSkills: boolean): string {
if (useSkills) {
return "Define a region of interest on an image by drawing a CYAN bounding box. Before calling: read skill 'process-image' (SKILL.md for an overview, tools/mask.md for multi-region and detect_object integration).";
}
return `Define a region of interest on an image by drawing a CYAN bounding box.
NOTE — If you have access to detect_object: Use detectLabel to place the mask directly on a detected object's bounding box.
- detectLabel: label or array of labels to match (e.g. "cat", ["left eye", "right eye"]) — case-insensitive substring match. Comma-separated string also accepted (multi-word labels supported).
- detectIndex: zero-based index or array of indices parallel to detectLabel (default: 0 per entry). Accepts bracket notation: "[2, 4, 7]".
- frameAdjust: expand (+) or shrink (-) each bbox uniformly. Number = % of bbox diagonal; string with 'px' suffix = pixels. E.g. 5, -3, "20px".
When detectLabel is omitted and N > 1 detections exist for a label, all are auto-expanded (Option A).
When detectLabel is a single string and detectIndex lists multiple indices, masks for each index are generated (Option B).
All labels must refer to detections on the same source image.
${canvasOrDetectResultExample(preset)}
Example: canvas=i3, detectLabel=["cat", "dog"], frameAdjust=5
Per-region crop overrides (multi-region only):
cropLeft / cropRight / cropTop / cropBottom each accept a SCALAR or an ARRAY parallel to detectLabel:
- Scalar: the same override is applied to every region.
- Array: one entry per region, in the same order as detectLabel / detectIndex.
Use null for any entry to keep that region's raw detection bbox (no override for that side).
The array may be shorter than the label list — missing trailing entries behave like null.
WARNING — cropX values are IMAGE-relative (% from that image border), not mask-relative:
cropBottom=30 means "place the bottom edge of the mask 30% up from the bottom of the image".
Whether this grows or shrinks the mask depends on where the detection was:
detection cropBottom=70 (face near top) + cropBottom=30 → mask GROWS downward
detection cropBottom=10 (face near bottom) + cropBottom=30 → mask SHRINKS upward
For predictable expand/shrink relative to the detection bbox, use frameAdjust instead.
Example (pull the bottom edge of the middle mask to 10% from the image bottom):
detectLabel=["cat", "dog", "bird"], cropBottom=[null, 10, null]
→ cat and bird keep their detection bbox; dog's bottom edge is set to 10% from bottom.
Whether this grows or shrinks dog's mask depends on where dog's detection was.
For predictable expand/shrink use frameAdjust instead.
imageFormat is scalar only and applies to single-region masks; ignored in multi-region mode.
IMPORTANT — Without detect_object (iterative procedure, do NOT set all sides at once):
1. Set cropLeft only → inspect result.
2. Keep cropLeft, set cropRight only → inspect.
3. Keep both, set cropTop only → inspect.
4. Set imageFormat to lock the aspect ratio (derives cropBottom automatically).
At tight crops, 1–3% per step is enough. Symmetric values do NOT guarantee a centred subject — judge by visual gap, not numbers.
Parameters:
- canvas: Source image. Notation: ${canvasNotationList(preset)}.
- cropLeft / cropRight / cropTop / cropBottom: Distance from that image border in % (or append px). IMAGE-relative: values are % of image dimension from that border. Valid range: 0–100 (left+right and top+bottom must each stay below 100). Negative values are invalid — they would place the edge outside the canvas. E.g. cropBottom=30 places the bottom edge 30% up from the image bottom. Scalar or per-region array (null = keep detection value). For relative expand/shrink use frameAdjust.
- imageFormat: Target aspect ratio (square=1:1, landscape=4:3, portrait=3:4, 16:9).
Only active when explicitly set. Ignored when all 4 sides are given.
Per axis: neither side → centred; one side → anchored, opposite defaults to 0; both → locked.
The result is saved as an iN image with cropLeft/cropRight/cropTop/cropBottom metadata.
When detectLabel produces multiple regions, all bounding boxes are stored (bboxes[]) and all drawn in cyan.
Pass it to inpaint or outpaint as canvas to use the marked region(s) as the mask.
Returns: The annotated image saved to the chat working directory as a new iN image.
${scratchpadFolderGuidance(preset, requireScratchpadFolder)}
${sourceNotationGuidance(preset)}`;
}
function zoomInDescription(preset: McpResultPreset, requireScratchpadFolder: boolean, useSkills: boolean): string {
if (useSkills) {
return "Re-render the cropped canvas region at full resolution via Draw Things image2image. Before calling: read skill 'process-image' (SKILL.md for an overview, tools/zoom-in.md for the crop + zoom-in workflow).";
}
return `Re-render the cropped canvas region at full resolution via Draw Things image2image.
Workflow: First use 'crop' (or 'detect_object') to select the region, then call 'zoom-in' on the source canvas.
zoom-in reads the stored crop metadata automatically — no crop parameters needed.
Alternatively, use detectLabel to target a detected object directly without a prior crop step:
- detectLabel: label text to match (e.g. cat, a person) — requires a prior detect_object run.
- detectIndex: zero-based index when multiple matches exist (default: 0).
- frameAdjust: expand (+) or shrink (-) the bounding box before rendering. Number = % of bbox dimensions (preserves AR); string with 'px' suffix = absolute pixel margin. E.g. 5, -3, 20px.
${canvasOrDetectResultExample(preset)}
Example: canvas=i3, detectLabel=cat, frameAdjust=8
Parameters:
- canvas: Source image. Notation: ${canvasNotationList(preset)}.
- width / height: Output dimensions. Default: derived from canvas aspect ratio.
- imageFormat: Target aspect ratio (square, landscape, portrait, 16:9). Expands the crop region to match the AR before re-rendering.
Returns: Inline preview, JSON with file URLs, crop info, model info, and clickable links.
${scratchpadFolderGuidance(preset, requireScratchpadFolder)}
${sourceNotationGuidance(preset)}`;
}
function inpaintDescription(preset: McpResultPreset, requireScratchpadFolder: boolean, useSkills: boolean): string {
if (useSkills) {
return "Repaint a masked region of the canvas by re-generating the area inside the mask. Before calling: read skill 'process-image' (SKILL.md for an overview, tools/inpaint.md for parameter details).";
}
return `Repaint a masked region of the canvas by re-generating the area inside the mask.
The area inside the mask is changed — the model fills it with new content based on the prompt.
The area outside the mask is protected and remains unchanged.
The canvas itself is never modified; the result is saved as a new image.
When canvas is a prior mask result (e.g. i8), the mask region is picked up automatically — no extra parameters needed.
Alternatively, use detectLabel to target one or more detected objects without a prior mask step:
- detectLabel: label or array of labels to match (e.g. cat, ["left eye", "right eye"]) — requires a prior detect_object run. Comma-separated string also accepted (multi-word labels supported).
- detectIndex: zero-based index or array of indices parallel to detectLabel (default: 0 per entry).
- frameAdjust: expand (+) or shrink (-) the detection box, applied uniformly to all regions. Number = % of bbox diagonal; string with 'px' suffix = pixels. E.g. 5, 20px, -10%.
When detectLabel is an array, all labels must refer to detections on the same source image.
${canvasOrDetectResultExample(preset)}
Parameters:
- canvas: Source image. Required. Notation: ${canvasNotationList(preset)}.
- prompt: What should appear in the repainted region. Default: empty (preserve content).
- model: Model preset for re-rendering. Default: auto.
- width / height: Output dimensions. Default: derived from canvas.
Returns: Inline preview, JSON with file URLs, model info, and clickable links.
${scratchpadFolderGuidance(preset, requireScratchpadFolder)}
${sourceNotationGuidance(preset)}`;
}
function outpaintDescription(preset: McpResultPreset, requireScratchpadFolder: boolean, useSkills: boolean): string {
if (useSkills) {
return "Extend the image beyond its masked region by regenerating the area outside the mask. Before calling: read skill 'process-image' (SKILL.md for an overview, tools/outpaint.md for parameter details).";
}
return `Extend the image beyond its masked region by regenerating the area outside the mask.
The area outside the mask is changed — the model fills it with new content based on the prompt.
The area inside the mask is protected and remains unchanged.
The canvas itself is never modified; the result is saved as a new image.
When canvas is a prior mask result (e.g. i8), the mask region is picked up automatically — no extra parameters needed.
Alternatively, use detectLabel to target one or more detected objects without a prior mask step:
- detectLabel: label or array of labels to match (e.g. cat, ["left eye", "right eye"]) — requires a prior detect_object run. Comma-separated string also accepted (multi-word labels supported).
- detectIndex: zero-based index or array of indices parallel to detectLabel (default: 0 per entry).
- frameAdjust: expand (+) or shrink (-) the detection box, applied uniformly to all regions. Number = % of bbox diagonal; string with 'px' suffix = pixels. E.g. 5, 20px, -10%.
When detectLabel is an array, all labels must refer to detections on the same source image.
${canvasOrDetectResultExample(preset)}
Parameters:
- canvas: Source image. Required. Notation: ${canvasNotationList(preset)}.
- prompt: What should appear in the extended area. Default: empty (preserve content).
- model: Model preset for re-rendering. Default: auto.
- width / height: Output dimensions. Default: derived from canvas.
Returns: Inline preview, JSON with file URLs, model info, and clickable links.
${scratchpadFolderGuidance(preset, requireScratchpadFolder)}
${sourceNotationGuidance(preset)}`;
}
// 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) }] };
}
/** Forwards a handler's ProgressCallback to MCP notifications/progress (shared formatting and ETA: made-for-bionic-core's progressReporter). */
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;
}
/**
* Runs a handler call, then diffs chat_media_state.json to find the iN entries it just
* appended (see resultMaterializer.ts) and turns them into a tool result, per the resolved
* preset (made-for-bionic-core's presets/{bionic,generic}.ts) and this tool's fixed tier
* (see planning/process-image-mcp-plan.md, "Materialisierungs-Stufen").
*/
async function runAndMaterialize(
tool: ToolName,
tier: ResultTier,
scratchpadPath: string,
scratchpadFolder: string | undefined,
preset: McpResultPreset,
renderArgs: Record<string, unknown>,
render: () => Promise<unknown>
): Promise<any> {
const before = await snapshotImageIndices(scratchpadPath);
const result = await render();
const record = result as { content?: unknown; isError?: unknown } | undefined;
if (record?.isError === true) return toToolResult(result);
const materialized = await materializeNewImages(scratchpadPath, before);
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, tier, scratchpadPath, scratchpadFolder, materialized, summaryText);
}
return buildGenericToolResult(tool, tier, scratchpadPath, scratchpadFolder, materialized, summaryText);
}
// 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: buildSingleResultText(tool, tier, materialized[0]!, summary) }] };
}
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: buildMultiResultText(tool, tier, reportPath, materialized) }] };
}
async function main(): Promise<void> {
// Logged BEFORE readMcpConfig() so a full, unfiltered record of what Bionic actually passed
// in always reaches process-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 });
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,
});
// 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("process-image", { projectRoot });
await logger.logEvent("skill-cache", { skill: "process-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, "process-image", "process-image", { projectRoot });
const imageDisplaySkillStatus = checkSkillStatus(preset, config.useSkills, "image-display", "process-image", { projectRoot });
await logger.logEvent("skill-status", { skill: "process-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/*.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("process-image", preset, { projectRoot });
await logger.logEvent("skill-sync", { skill: "process-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("process-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, "process-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: "process-image-mcp",
version: "0.1.0",
buildServer: (server) => {
function register(
name: ToolName,
tier: ResultTier,
schema: z.ZodTypeAny,
description: (preset: McpResultPreset, requireScratchpadFolder: boolean, useSkills: boolean) => string,
// 2nd handler param differs per tool: an onProgress callback for zoom-in/inpaint/outpaint
// (supportsProgress=true), or an optional requestId string for crop/mask (always undefined
// here since supportsProgress=false for those — the handler self-generates one, then
// includes it in both its audit-log entry and its returned summary, which
// runAndMaterialize's own summary?.requestId fallback below picks up automatically).
handlerImport: () => Promise<(args: Record<string, unknown>, second?: any) => Promise<unknown>>,
supportsProgress: boolean,
extraHiddenFields: readonly string[] = []
): void {
server.registerTool(
name,
{ description: description(preset, preset.scratchpadFolderRequired, effectiveUseSkillsForDescriptions), inputSchema: fromJsonSchema(toMcpInputSchema(schema, preset.scratchpadFolderRequired, preset, extraHiddenFields) 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: name, scratchpadPath });
// quality (+ extraHiddenFields, e.g. zoom-in's model) are hidden from the schema above;
// also strip them here so they can never reach the backend even if the caller sends
// them anyway — always resolves to the "auto" default.
const { scratchpadFolder: _scratchpadFolder, quality: _quality, ...renderArgsRaw } = toolArgs;
for (const field of extraHiddenFields) delete (renderArgsRaw as Record<string, unknown>)[field];
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);
const onProgress = supportsProgress ? createProgressForwarder(ctx, name) : undefined;
return withSkillNotice(
await runAndMaterialize(name, tier, scratchpadPath, toolArgs.scratchpadFolder, preset, renderArgs, async () => {
const handle = await handlerImport();
return handle(renderArgs, onProgress);
})
);
} catch (error) {
return await bridgeToolErrorResult(name, error, logger, preset);
}
}
);
}
register("crop", "process", CropToolSchemaStrict, cropDescription, async () => (await import("../core/tools.js")).handleCrop, false);
register("mask", "process", CropToolSchemaStrict, maskDescription, async () => (await import("../core/tools.js")).handleMask, false);
// model hidden VORLÄUFIG: zoom-in has exactly one valid value ("auto" → SeedVR2) today;
// re-expose once a second zoom-capable model exists.
register("zoom-in", "final", ZoomInToolSchemaStrict, zoomInDescription, async () => (await import("../core/tools.js")).handleZoomIn, true, ["model"]);
register("inpaint", "final", InpaintToolSchemaStrict, inpaintDescription, async () => (await import("../core/tools.js")).handleInpaint, true);
register("outpaint", "final", OutpaintToolSchemaStrict, outpaintDescription, async () => (await import("../core/tools.js")).handleOutpaint, true);
},
});
}
main();
import crypto from "node:crypto";
import { fromJsonSchema } from "@modelcontextprotocol/server";
import { zodToJsonSchema } from "zod-to-json-schema";
import type { z } from "zod";
import {
bridgeToolErrorResult,
startStdioMcpServer,
looksLikeSourceNotation,
normalizeSourceFieldToArray,
resolveMcpSourceToken,
checkSkillStatus,
createSkillNoticeProvider,
createSkillFileUpdateNotice,
ensureLocalSkillCache,
syncSkillToInstallTarget,
createMcpProgressSender,
createProgressReporter,
type ProgressCallback,
type McpResultPreset,
type ResultTier,
} from "./core-bundle.mjs";
import { CropToolSchemaStrict, InpaintToolSchemaStrict, OutpaintToolSchemaStrict, ZoomInToolSchemaStrict, 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, materializeNewImages, snapshotImageIndices, writeHtmlReport } from "./resultMaterializer.js";
import { resolveEffectiveScratchpadPath } from "./scratchpadResolution.js";
import { buildGenericToolResult, buildMultiResultText, buildSingleResultText, buildUnslothToolResult } from "./toolResults.js";
type ToolName = "crop" | "mask" | "zoom-in" | "inpaint" | "outpaint";
type ToolArgs = Record<string, unknown> & { scratchpadFolder?: string };
/**
* 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/inpaint/outpaint logic matches
* the raw `canvas` string against stored `detectSource`/`cropSource` 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 each plugin's 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];
}
/**
* zod-to-json-schema collapses a union of only-primitive types (e.g. cropLeft/cropRight/cropTop/
* cropBottom/frameAdjust's `z.union([z.number(), z.string()])`) into a single node with
* `type: ["number", "string"]` instead of `anyOf` branches. Several MCP clients only accept
* `type` as a single string and either reject the tool or silently drop the constraint on such a
* node. Recursively rewrites every such node into `{ anyOf: [{ type: "number" }, { type: "string" }], ...rest }`,
* walking into properties/items/anyOf/allOf/oneOf/additionalProperties so nested cases (e.g. an
* array's items) are fixed too.
*/
function splitTypeArrayUnions(node: unknown): unknown {
if (Array.isArray(node)) return node.map(splitTypeArrayUnions);
if (node === null || typeof node !== "object") return node;
const { type, properties, items, anyOf, allOf, oneOf, additionalProperties, ...rest } = node as Record<string, unknown>;
const walked: Record<string, unknown> = { ...rest };
if (properties && typeof properties === "object") {
walked.properties = Object.fromEntries(Object.entries(properties as Record<string, unknown>).map(([key, value]) => [key, splitTypeArrayUnions(value)]));
}
if (items !== undefined) walked.items = splitTypeArrayUnions(items);
if (additionalProperties !== undefined) walked.additionalProperties = splitTypeArrayUnions(additionalProperties);
if (anyOf !== undefined) walked.anyOf = (anyOf as unknown[]).map(splitTypeArrayUnions);
if (allOf !== undefined) walked.allOf = (allOf as unknown[]).map(splitTypeArrayUnions);
if (oneOf !== undefined) walked.oneOf = (oneOf as unknown[]).map(splitTypeArrayUnions);
if (Array.isArray(type)) {
walked.anyOf = [...(Array.isArray(walked.anyOf) ? (walked.anyOf as unknown[]) : []), ...type.map((t) => ({ type: t }))];
} else if (type !== undefined) {
walked.type = type;
}
return walked;
}
/**
* Only canvas/moodboard fields actually accept aN (see sourceNotation.ts) — unsloth's own
* attachment-inventory hint (see ATTACHMENT_INVENTORY_HINT below) must only ever be appended to
* those, not to a field that merely MENTIONS 'a1' as an illustrative example of a canvas value
* (e.g. detectLabel's own "canvas may be the original source (e.g. 'a1') ..." prose) — appending
* it there would be nonsensical (detectLabel isn't a source/attachment field).
*/
const SOURCE_NOTATION_FIELD_NAMES = new Set(["canvas", "moodboard"]);
/**
* 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 everywhere canvas notation comes up (not just on the canvas
* field itself, e.g. detectLabel's own "(e.g. 'a1')" aside), 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 every 'a1' mention from the JSON schema text actually
* sent to bionic/generic MCP clients, leaving schemas.ts untouched. Applies to ANY description,
* not just canvas/moodboard — bionic/generic must never see an aN example anywhere.
*/
// 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((item) => stripAttachmentNotationMentions(item));
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. Scoped to canvas/moodboard fields only — see
* SOURCE_NOTATION_FIELD_NAMES's doc comment above.
*/
function annotateAttachmentNotationForUnsloth(node: unknown, parentKey?: string): unknown {
if (Array.isArray(node)) return node.map((item) => annotateAttachmentNotationForUnsloth(item, parentKey));
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>)) {
if (key === "description" && typeof value === "string") {
out[key] = parentKey && SOURCE_NOTATION_FIELD_NAMES.has(parentKey) && value.includes("'a1'") ? `${value}${ATTACHMENT_INVENTORY_HINT}` : value;
} else {
out[key] = annotateAttachmentNotationForUnsloth(value, key);
}
}
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` (a no-op for crop/mask, whose schemas have no such field),
* mirroring the plugin's own tools, 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. `extraHiddenFields` additionally hides per-tool fields (e.g. zoom-in's `model`, which
* has exactly one valid value until a second zoom-capable model exists — see register()'s call).
*/
function toMcpInputSchema(zodSchema: z.ZodTypeAny, requireScratchpadFolder: boolean, preset: McpResultPreset, extraHiddenFields: readonly string[] = []): Record<string, unknown> {
const { $schema: _$schema, ...rawSchema } = zodToJsonSchema(zodSchema) as Record<string, any>;
const schema = (preset.name === "unsloth" ? annotateAttachmentNotationForUnsloth(splitTypeArrayUnions(rawSchema)) : stripAttachmentNotationMentions(splitTypeArrayUnions(rawSchema))) as Record<string, any>;
const hiddenFields = new Set<string>(["quality", ...extraHiddenFields]);
const visibleProperties = Object.fromEntries(Object.entries((schema.properties ?? {}) as Record<string, unknown>).filter(([key]) => !hiddenFields.has(key)));
const baseRequired = ((schema.required as string[] | undefined) ?? []).filter((key) => !hiddenFields.has(key));
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,
};
}
const SOURCE_NOTATION_GUIDANCE =
"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.";
function sourceNotationGuidance(preset: McpResultPreset): string {
if (preset.name !== "unsloth") return `${SOURCE_NOTATION_GUIDANCE} A file the user attached to the chat must be referenced by its filename (basename).`;
return `${SOURCE_NOTATION_GUIDANCE} 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".';
}
/** aN is only ever valid canvas notation for the unsloth preset (see sourceNotation.ts) — bionic/generic must never see it mentioned, not even as an example. */
function canvasNotationList(preset: McpResultPreset): string {
return preset.name === "unsloth" ? "a1, v2, p1, i3" : "v2, p1, i3";
}
/** Same rule as canvasNotationList() — see its doc comment. */
function canvasOrDetectResultExample(preset: McpResultPreset): string {
return preset.name === "unsloth"
? "canvas may be the original source (e.g. a1) or the annotated detect_object result (e.g. i3)."
: "canvas may be the original source or the annotated detect_object result (e.g. i3).";
}
function cropDescription(preset: McpResultPreset, requireScratchpadFolder: boolean, useSkills: boolean): string {
if (useSkills) {
return "Crop an image by adjusting one axis at a time. Before calling: read skill 'process-image' (SKILL.md for an overview, tools/crop.md for the iterative crop procedure and detect_object integration).";
}
return `Crop an image by adjusting one axis at a time.
NOTE — If you have access to detect_object: Use detectLabel to crop directly to a detected object's bounding box.
- detectLabel: label text to match (e.g. cat, a person) — case-insensitive substring match.
- detectIndex: zero-based index when multiple matches exist (default: 0).
- frameAdjust: expand (+) or shrink (-) the box. Number = % of bbox dimensions (preserves AR); string with 'px' suffix = absolute pixel margin. E.g. 5, -3, 20px.
${canvasOrDetectResultExample(preset)}
Example: canvas=i3, detectLabel=cat, frameAdjust=5
IMPORTANT — Without detect_object (iterative procedure, do NOT set all sides at once):
1. Set cropLeft only → inspect result.
2. Keep cropLeft, set cropRight only → inspect.
3. Keep both, set cropTop only → inspect.
4. Set imageFormat to lock the aspect ratio (derives cropBottom automatically).
At tight crops, 1–3% per step is enough. Symmetric values do NOT guarantee a centred subject — judge by visual gap, not numbers.
Parameters:
- canvas: Source image. Notation: ${canvasNotationList(preset)}.
- cropLeft / cropRight / cropTop / cropBottom: Amount to remove per edge. Default unit: % (0–99). Optionally append px for absolute pixels (e.g. 120px or 10%).
- imageFormat: Target aspect ratio (square=1:1, landscape=4:3, portrait=3:4, 16:9).
Only active when explicitly set. Ignored when all 4 sides are given.
Per axis: neither side → centred; one side → anchored, opposite defaults to 0; both → locked.
Examples:
imageFormat=portrait → centred 3:4 crop
cropLeft=10, imageFormat=16:9 → anchor left at 10%, centre vertically
cropLeft=15, cropRight=15, cropTop=10, cropBottom=10 → all 4 explicit, imageFormat ignored
Returns: The cropped image saved to the chat working directory as a new variant.
${scratchpadFolderGuidance(preset, requireScratchpadFolder)}
${sourceNotationGuidance(preset)}`;
}
function maskDescription(preset: McpResultPreset, requireScratchpadFolder: boolean, useSkills: boolean): string {
if (useSkills) {
return "Define a region of interest on an image by drawing a CYAN bounding box. Before calling: read skill 'process-image' (SKILL.md for an overview, tools/mask.md for multi-region and detect_object integration).";
}
return `Define a region of interest on an image by drawing a CYAN bounding box.
NOTE — If you have access to detect_object: Use detectLabel to place the mask directly on a detected object's bounding box.
- detectLabel: label or array of labels to match (e.g. "cat", ["left eye", "right eye"]) — case-insensitive substring match. Comma-separated string also accepted (multi-word labels supported).
- detectIndex: zero-based index or array of indices parallel to detectLabel (default: 0 per entry). Accepts bracket notation: "[2, 4, 7]".
- frameAdjust: expand (+) or shrink (-) each bbox uniformly. Number = % of bbox diagonal; string with 'px' suffix = pixels. E.g. 5, -3, "20px".
When detectLabel is omitted and N > 1 detections exist for a label, all are auto-expanded (Option A).
When detectLabel is a single string and detectIndex lists multiple indices, masks for each index are generated (Option B).
All labels must refer to detections on the same source image.
${canvasOrDetectResultExample(preset)}
Example: canvas=i3, detectLabel=["cat", "dog"], frameAdjust=5
Per-region crop overrides (multi-region only):
cropLeft / cropRight / cropTop / cropBottom each accept a SCALAR or an ARRAY parallel to detectLabel:
- Scalar: the same override is applied to every region.
- Array: one entry per region, in the same order as detectLabel / detectIndex.
Use null for any entry to keep that region's raw detection bbox (no override for that side).
The array may be shorter than the label list — missing trailing entries behave like null.
WARNING — cropX values are IMAGE-relative (% from that image border), not mask-relative:
cropBottom=30 means "place the bottom edge of the mask 30% up from the bottom of the image".
Whether this grows or shrinks the mask depends on where the detection was:
detection cropBottom=70 (face near top) + cropBottom=30 → mask GROWS downward
detection cropBottom=10 (face near bottom) + cropBottom=30 → mask SHRINKS upward
For predictable expand/shrink relative to the detection bbox, use frameAdjust instead.
Example (pull the bottom edge of the middle mask to 10% from the image bottom):
detectLabel=["cat", "dog", "bird"], cropBottom=[null, 10, null]
→ cat and bird keep their detection bbox; dog's bottom edge is set to 10% from bottom.
Whether this grows or shrinks dog's mask depends on where dog's detection was.
For predictable expand/shrink use frameAdjust instead.
imageFormat is scalar only and applies to single-region masks; ignored in multi-region mode.
IMPORTANT — Without detect_object (iterative procedure, do NOT set all sides at once):
1. Set cropLeft only → inspect result.
2. Keep cropLeft, set cropRight only → inspect.
3. Keep both, set cropTop only → inspect.
4. Set imageFormat to lock the aspect ratio (derives cropBottom automatically).
At tight crops, 1–3% per step is enough. Symmetric values do NOT guarantee a centred subject — judge by visual gap, not numbers.
Parameters:
- canvas: Source image. Notation: ${canvasNotationList(preset)}.
- cropLeft / cropRight / cropTop / cropBottom: Distance from that image border in % (or append px). IMAGE-relative: values are % of image dimension from that border. Valid range: 0–100 (left+right and top+bottom must each stay below 100). Negative values are invalid — they would place the edge outside the canvas. E.g. cropBottom=30 places the bottom edge 30% up from the image bottom. Scalar or per-region array (null = keep detection value). For relative expand/shrink use frameAdjust.
- imageFormat: Target aspect ratio (square=1:1, landscape=4:3, portrait=3:4, 16:9).
Only active when explicitly set. Ignored when all 4 sides are given.
Per axis: neither side → centred; one side → anchored, opposite defaults to 0; both → locked.
The result is saved as an iN image with cropLeft/cropRight/cropTop/cropBottom metadata.
When detectLabel produces multiple regions, all bounding boxes are stored (bboxes[]) and all drawn in cyan.
Pass it to inpaint or outpaint as canvas to use the marked region(s) as the mask.
Returns: The annotated image saved to the chat working directory as a new iN image.
${scratchpadFolderGuidance(preset, requireScratchpadFolder)}
${sourceNotationGuidance(preset)}`;
}
function zoomInDescription(preset: McpResultPreset, requireScratchpadFolder: boolean, useSkills: boolean): string {
if (useSkills) {
return "Re-render the cropped canvas region at full resolution via Draw Things image2image. Before calling: read skill 'process-image' (SKILL.md for an overview, tools/zoom-in.md for the crop + zoom-in workflow).";
}
return `Re-render the cropped canvas region at full resolution via Draw Things image2image.
Workflow: First use 'crop' (or 'detect_object') to select the region, then call 'zoom-in' on the source canvas.
zoom-in reads the stored crop metadata automatically — no crop parameters needed.
Alternatively, use detectLabel to target a detected object directly without a prior crop step:
- detectLabel: label text to match (e.g. cat, a person) — requires a prior detect_object run.
- detectIndex: zero-based index when multiple matches exist (default: 0).
- frameAdjust: expand (+) or shrink (-) the bounding box before rendering. Number = % of bbox dimensions (preserves AR); string with 'px' suffix = absolute pixel margin. E.g. 5, -3, 20px.
${canvasOrDetectResultExample(preset)}
Example: canvas=i3, detectLabel=cat, frameAdjust=8
Parameters:
- canvas: Source image. Notation: ${canvasNotationList(preset)}.
- width / height: Output dimensions. Default: derived from canvas aspect ratio.
- imageFormat: Target aspect ratio (square, landscape, portrait, 16:9). Expands the crop region to match the AR before re-rendering.
Returns: Inline preview, JSON with file URLs, crop info, model info, and clickable links.
${scratchpadFolderGuidance(preset, requireScratchpadFolder)}
${sourceNotationGuidance(preset)}`;
}
function inpaintDescription(preset: McpResultPreset, requireScratchpadFolder: boolean, useSkills: boolean): string {
if (useSkills) {
return "Repaint a masked region of the canvas by re-generating the area inside the mask. Before calling: read skill 'process-image' (SKILL.md for an overview, tools/inpaint.md for parameter details).";
}
return `Repaint a masked region of the canvas by re-generating the area inside the mask.
The area inside the mask is changed — the model fills it with new content based on the prompt.
The area outside the mask is protected and remains unchanged.
The canvas itself is never modified; the result is saved as a new image.
When canvas is a prior mask result (e.g. i8), the mask region is picked up automatically — no extra parameters needed.
Alternatively, use detectLabel to target one or more detected objects without a prior mask step:
- detectLabel: label or array of labels to match (e.g. cat, ["left eye", "right eye"]) — requires a prior detect_object run. Comma-separated string also accepted (multi-word labels supported).
- detectIndex: zero-based index or array of indices parallel to detectLabel (default: 0 per entry).
- frameAdjust: expand (+) or shrink (-) the detection box, applied uniformly to all regions. Number = % of bbox diagonal; string with 'px' suffix = pixels. E.g. 5, 20px, -10%.
When detectLabel is an array, all labels must refer to detections on the same source image.
${canvasOrDetectResultExample(preset)}
Parameters:
- canvas: Source image. Required. Notation: ${canvasNotationList(preset)}.
- prompt: What should appear in the repainted region. Default: empty (preserve content).
- model: Model preset for re-rendering. Default: auto.
- width / height: Output dimensions. Default: derived from canvas.
Returns: Inline preview, JSON with file URLs, model info, and clickable links.
${scratchpadFolderGuidance(preset, requireScratchpadFolder)}
${sourceNotationGuidance(preset)}`;
}
function outpaintDescription(preset: McpResultPreset, requireScratchpadFolder: boolean, useSkills: boolean): string {
if (useSkills) {
return "Extend the image beyond its masked region by regenerating the area outside the mask. Before calling: read skill 'process-image' (SKILL.md for an overview, tools/outpaint.md for parameter details).";
}
return `Extend the image beyond its masked region by regenerating the area outside the mask.
The area outside the mask is changed — the model fills it with new content based on the prompt.
The area inside the mask is protected and remains unchanged.
The canvas itself is never modified; the result is saved as a new image.
When canvas is a prior mask result (e.g. i8), the mask region is picked up automatically — no extra parameters needed.
Alternatively, use detectLabel to target one or more detected objects without a prior mask step:
- detectLabel: label or array of labels to match (e.g. cat, ["left eye", "right eye"]) — requires a prior detect_object run. Comma-separated string also accepted (multi-word labels supported).
- detectIndex: zero-based index or array of indices parallel to detectLabel (default: 0 per entry).
- frameAdjust: expand (+) or shrink (-) the detection box, applied uniformly to all regions. Number = % of bbox diagonal; string with 'px' suffix = pixels. E.g. 5, 20px, -10%.
When detectLabel is an array, all labels must refer to detections on the same source image.
${canvasOrDetectResultExample(preset)}
Parameters:
- canvas: Source image. Required. Notation: ${canvasNotationList(preset)}.
- prompt: What should appear in the extended area. Default: empty (preserve content).
- model: Model preset for re-rendering. Default: auto.
- width / height: Output dimensions. Default: derived from canvas.
Returns: Inline preview, JSON with file URLs, model info, and clickable links.
${scratchpadFolderGuidance(preset, requireScratchpadFolder)}
${sourceNotationGuidance(preset)}`;
}
// 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) }] };
}
/** Forwards a handler's ProgressCallback to MCP notifications/progress (shared formatting and ETA: made-for-bionic-core's progressReporter). */
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;
}
/**
* Runs a handler call, then diffs chat_media_state.json to find the iN entries it just
* appended (see resultMaterializer.ts) and turns them into a tool result, per the resolved
* preset (made-for-bionic-core's presets/{bionic,generic}.ts) and this tool's fixed tier
* (see planning/process-image-mcp-plan.md, "Materialisierungs-Stufen").
*/
async function runAndMaterialize(
tool: ToolName,
tier: ResultTier,
scratchpadPath: string,
scratchpadFolder: string | undefined,
preset: McpResultPreset,
renderArgs: Record<string, unknown>,
render: () => Promise<unknown>
): Promise<any> {
const before = await snapshotImageIndices(scratchpadPath);
const result = await render();
const record = result as { content?: unknown; isError?: unknown } | undefined;
if (record?.isError === true) return toToolResult(result);
const materialized = await materializeNewImages(scratchpadPath, before);
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, tier, scratchpadPath, scratchpadFolder, materialized, summaryText);
}
return buildGenericToolResult(tool, tier, scratchpadPath, scratchpadFolder, materialized, summaryText);
}
// 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: buildSingleResultText(tool, tier, materialized[0]!, summary) }] };
}
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: buildMultiResultText(tool, tier, reportPath, materialized) }] };
}
async function main(): Promise<void> {
// Logged BEFORE readMcpConfig() so a full, unfiltered record of what Bionic actually passed
// in always reaches process-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 });
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,
});
// 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("process-image", { projectRoot });
await logger.logEvent("skill-cache", { skill: "process-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, "process-image", "process-image", { projectRoot });
const imageDisplaySkillStatus = checkSkillStatus(preset, config.useSkills, "image-display", "process-image", { projectRoot });
await logger.logEvent("skill-status", { skill: "process-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/*.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("process-image", preset, { projectRoot });
await logger.logEvent("skill-sync", { skill: "process-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("process-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, "process-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: "process-image-mcp",
version: "0.1.0",
buildServer: (server) => {
function register(
name: ToolName,
tier: ResultTier,
schema: z.ZodTypeAny,
description: (preset: McpResultPreset, requireScratchpadFolder: boolean, useSkills: boolean) => string,
// 2nd handler param differs per tool: an onProgress callback for zoom-in/inpaint/outpaint
// (supportsProgress=true), or an optional requestId string for crop/mask (always undefined
// here since supportsProgress=false for those — the handler self-generates one, then
// includes it in both its audit-log entry and its returned summary, which
// runAndMaterialize's own summary?.requestId fallback below picks up automatically).
handlerImport: () => Promise<(args: Record<string, unknown>, second?: any) => Promise<unknown>>,
supportsProgress: boolean,
extraHiddenFields: readonly string[] = []
): void {
server.registerTool(
name,
{ description: description(preset, preset.scratchpadFolderRequired, effectiveUseSkillsForDescriptions), inputSchema: fromJsonSchema(toMcpInputSchema(schema, preset.scratchpadFolderRequired, preset, extraHiddenFields) 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: name, scratchpadPath });
// quality (+ extraHiddenFields, e.g. zoom-in's model) are hidden from the schema above;
// also strip them here so they can never reach the backend even if the caller sends
// them anyway — always resolves to the "auto" default.
const { scratchpadFolder: _scratchpadFolder, quality: _quality, ...renderArgsRaw } = toolArgs;
for (const field of extraHiddenFields) delete (renderArgsRaw as Record<string, unknown>)[field];
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);
const onProgress = supportsProgress ? createProgressForwarder(ctx, name) : undefined;
return withSkillNotice(
await runAndMaterialize(name, tier, scratchpadPath, toolArgs.scratchpadFolder, preset, renderArgs, async () => {
const handle = await handlerImport();
return handle(renderArgs, onProgress);
})
);
} catch (error) {
return await bridgeToolErrorResult(name, error, logger, preset);
}
}
);
}
register("crop", "process", CropToolSchemaStrict, cropDescription, async () => (await import("../core/tools.js")).handleCrop, false);
register("mask", "process", CropToolSchemaStrict, maskDescription, async () => (await import("../core/tools.js")).handleMask, false);
// model hidden VORLÄUFIG: zoom-in has exactly one valid value ("auto" → SeedVR2) today;
// re-expose once a second zoom-capable model exists.
register("zoom-in", "final", ZoomInToolSchemaStrict, zoomInDescription, async () => (await import("../core/tools.js")).handleZoomIn, true, ["model"]);
register("inpaint", "final", InpaintToolSchemaStrict, inpaintDescription, async () => (await import("../core/tools.js")).handleInpaint, true);
register("outpaint", "final", OutpaintToolSchemaStrict, outpaintDescription, async () => (await import("../core/tools.js")).handleOutpaint, true);
},
});
}
main();