dist-mcp / mcp / index.js
dist-mcp / mcp / index.js
import crypto from "node:crypto";
import { fromJsonSchema } from "@modelcontextprotocol/server";
import { zodToJsonSchema } from "zod-to-json-schema";
import { bridgeToolErrorResult, startStdioMcpServer, looksLikeSourceNotation, normalizeSourceFieldToArray, resolveMcpSourceToken } from "./core-bundle.mjs";
import { CropToolSchemaStrict, InpaintToolSchemaStrict, OutpaintToolSchemaStrict, ZoomInToolSchemaStrict } from "../core-bundle.mjs";
import { applyMcpConfig, getRawEnvSnapshot, initCustomConfigs, readMcpConfig } 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";
/**
* 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, toolArgs, scratchpadPath, renderArgs) {
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` 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. Mutates `renderArgs` in place.
*/
async function preResolveCanvasField(renderArgs, field, scratchpadPath) {
const raw = renderArgs[field];
if (raw === undefined)
return;
const wasArray = Array.isArray(raw);
const tokens = normalizeSourceFieldToArray(raw);
if (tokens.length === 0)
return;
const resolved = [];
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) {
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;
const walked = { ...rest };
if (properties && typeof properties === "object") {
walked.properties = Object.fromEntries(Object.entries(properties).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.map(splitTypeArrayUnions);
if (allOf !== undefined)
walked.allOf = allOf.map(splitTypeArrayUnions);
if (oneOf !== undefined)
walked.oneOf = oneOf.map(splitTypeArrayUnions);
if (Array.isArray(type)) {
walked.anyOf = [...(Array.isArray(walked.anyOf) ? walked.anyOf : []), ...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.
*/
function stripAttachmentNotationMentions(node) {
if (Array.isArray(node))
return node.map((item) => stripAttachmentNotationMentions(item));
if (node === null || typeof node !== "object")
return node;
const out = {};
for (const [key, value] of Object.entries(node)) {
out[key] =
key === "description" && typeof value === "string"
? value.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, parentKey) {
if (Array.isArray(node))
return node.map((item) => annotateAttachmentNotationForUnsloth(item, parentKey));
if (node === null || typeof node !== "object")
return node;
const out = {};
for (const [key, value] of Object.entries(node)) {
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.
*/
function toMcpInputSchema(zodSchema, requireScratchpadFolder, preset) {
const { $schema: _$schema, ...rawSchema } = zodToJsonSchema(zodSchema);
const schema = (preset.name === "unsloth" ? annotateAttachmentNotationForUnsloth(splitTypeArrayUnions(rawSchema)) : stripAttachmentNotationMentions(splitTypeArrayUnions(rawSchema)));
const { quality: _quality, ...visibleProperties } = (schema.properties ?? {});
const baseRequired = (schema.required ?? []).filter((key) => key !== "quality");
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) {
if (preset.name !== "unsloth")
return SOURCE_NOTATION_GUIDANCE;
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, requireScratchpadFolder) {
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) {
return preset.name === "unsloth" ? "a1, v2, p1, i3" : "v2, p1, i3";
}
/** Same rule as canvasNotationList() — see its doc comment. */
function canvasOrDetectResultExample(preset) {
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, requireScratchpadFolder) {
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, requireScratchpadFolder) {
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, requireScratchpadFolder) {
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, requireScratchpadFolder) {
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, requireScratchpadFolder) {
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) {
const record = result;
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's notifications/progress (see generate-image/src/mcp/index.ts, same mechanism). */
function createProgressForwarder(ctx, tool) {
const mcpReq = ctx?.mcpReq;
const progressToken = mcpReq?._meta?.progressToken;
if (progressToken === undefined || typeof mcpReq?.notify !== "function")
return undefined;
let lastProgress = 0;
return (step, totalSteps, message) => {
let statusText;
if (step === -1 && message) {
statusText = message;
}
else if (totalSteps && totalSteps > 0) {
const pct = Math.round((step / (totalSteps + 1)) * 100);
statusText = `Step ${step}/${totalSteps} (${pct}%)`;
}
else {
statusText = `Step ${step}...`;
}
if (step >= 0) {
lastProgress = Math.max(lastProgress, step);
}
else if (lastProgress > 0 && typeof totalSteps === "number") {
lastProgress = totalSteps + 1;
}
mcpReq.notify({
method: "notifications/progress",
params: { progressToken, progress: lastProgress, message: statusText },
}).catch((error) => {
logger.logError("progress-notify-failed", error, { tool }).catch(() => { });
});
};
}
/**
* 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, tier, scratchpadPath, scratchpadFolder, preset, renderArgs, render) {
const before = await snapshotImageIndices(scratchpadPath);
const result = await render();
const record = result;
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() {
// 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,
nodeVersion: process.version,
});
startStdioMcpServer({
name: "process-image-mcp",
version: "0.1.0",
buildServer: (server) => {
function register(name, tier, schema, description,
// 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, supportsProgress) {
server.registerTool(name, { description: description(preset, preset.scratchpadFolderRequired), inputSchema: fromJsonSchema(toMcpInputSchema(schema, preset.scratchpadFolderRequired, preset)) }, async (args, ctx) => {
const toolArgs = args;
try {
const scratchpadPath = await resolveEffectiveScratchpadPath(config.chatWorkingDirectories, toolArgs.scratchpadFolder, preset.scratchpadFolderRequired, preset);
await logger.logEvent("tool-request", { tool: name, 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 = supportsProgress ? createProgressForwarder(ctx, name) : undefined;
return 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);
register("zoom-in", "final", ZoomInToolSchemaStrict, zoomInDescription, async () => (await import("../core/tools.js")).handleZoomIn, true);
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 { bridgeToolErrorResult, startStdioMcpServer, looksLikeSourceNotation, normalizeSourceFieldToArray, resolveMcpSourceToken } from "./core-bundle.mjs";
import { CropToolSchemaStrict, InpaintToolSchemaStrict, OutpaintToolSchemaStrict, ZoomInToolSchemaStrict } from "../core-bundle.mjs";
import { applyMcpConfig, getRawEnvSnapshot, initCustomConfigs, readMcpConfig } 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";
/**
* 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, toolArgs, scratchpadPath, renderArgs) {
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` 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. Mutates `renderArgs` in place.
*/
async function preResolveCanvasField(renderArgs, field, scratchpadPath) {
const raw = renderArgs[field];
if (raw === undefined)
return;
const wasArray = Array.isArray(raw);
const tokens = normalizeSourceFieldToArray(raw);
if (tokens.length === 0)
return;
const resolved = [];
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) {
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;
const walked = { ...rest };
if (properties && typeof properties === "object") {
walked.properties = Object.fromEntries(Object.entries(properties).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.map(splitTypeArrayUnions);
if (allOf !== undefined)
walked.allOf = allOf.map(splitTypeArrayUnions);
if (oneOf !== undefined)
walked.oneOf = oneOf.map(splitTypeArrayUnions);
if (Array.isArray(type)) {
walked.anyOf = [...(Array.isArray(walked.anyOf) ? walked.anyOf : []), ...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.
*/
function stripAttachmentNotationMentions(node) {
if (Array.isArray(node))
return node.map((item) => stripAttachmentNotationMentions(item));
if (node === null || typeof node !== "object")
return node;
const out = {};
for (const [key, value] of Object.entries(node)) {
out[key] =
key === "description" && typeof value === "string"
? value.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, parentKey) {
if (Array.isArray(node))
return node.map((item) => annotateAttachmentNotationForUnsloth(item, parentKey));
if (node === null || typeof node !== "object")
return node;
const out = {};
for (const [key, value] of Object.entries(node)) {
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.
*/
function toMcpInputSchema(zodSchema, requireScratchpadFolder, preset) {
const { $schema: _$schema, ...rawSchema } = zodToJsonSchema(zodSchema);
const schema = (preset.name === "unsloth" ? annotateAttachmentNotationForUnsloth(splitTypeArrayUnions(rawSchema)) : stripAttachmentNotationMentions(splitTypeArrayUnions(rawSchema)));
const { quality: _quality, ...visibleProperties } = (schema.properties ?? {});
const baseRequired = (schema.required ?? []).filter((key) => key !== "quality");
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) {
if (preset.name !== "unsloth")
return SOURCE_NOTATION_GUIDANCE;
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, requireScratchpadFolder) {
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) {
return preset.name === "unsloth" ? "a1, v2, p1, i3" : "v2, p1, i3";
}
/** Same rule as canvasNotationList() — see its doc comment. */
function canvasOrDetectResultExample(preset) {
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, requireScratchpadFolder) {
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, requireScratchpadFolder) {
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, requireScratchpadFolder) {
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, requireScratchpadFolder) {
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, requireScratchpadFolder) {
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) {
const record = result;
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's notifications/progress (see generate-image/src/mcp/index.ts, same mechanism). */
function createProgressForwarder(ctx, tool) {
const mcpReq = ctx?.mcpReq;
const progressToken = mcpReq?._meta?.progressToken;
if (progressToken === undefined || typeof mcpReq?.notify !== "function")
return undefined;
let lastProgress = 0;
return (step, totalSteps, message) => {
let statusText;
if (step === -1 && message) {
statusText = message;
}
else if (totalSteps && totalSteps > 0) {
const pct = Math.round((step / (totalSteps + 1)) * 100);
statusText = `Step ${step}/${totalSteps} (${pct}%)`;
}
else {
statusText = `Step ${step}...`;
}
if (step >= 0) {
lastProgress = Math.max(lastProgress, step);
}
else if (lastProgress > 0 && typeof totalSteps === "number") {
lastProgress = totalSteps + 1;
}
mcpReq.notify({
method: "notifications/progress",
params: { progressToken, progress: lastProgress, message: statusText },
}).catch((error) => {
logger.logError("progress-notify-failed", error, { tool }).catch(() => { });
});
};
}
/**
* 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, tier, scratchpadPath, scratchpadFolder, preset, renderArgs, render) {
const before = await snapshotImageIndices(scratchpadPath);
const result = await render();
const record = result;
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() {
// 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,
nodeVersion: process.version,
});
startStdioMcpServer({
name: "process-image-mcp",
version: "0.1.0",
buildServer: (server) => {
function register(name, tier, schema, description,
// 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, supportsProgress) {
server.registerTool(name, { description: description(preset, preset.scratchpadFolderRequired), inputSchema: fromJsonSchema(toMcpInputSchema(schema, preset.scratchpadFolderRequired, preset)) }, async (args, ctx) => {
const toolArgs = args;
try {
const scratchpadPath = await resolveEffectiveScratchpadPath(config.chatWorkingDirectories, toolArgs.scratchpadFolder, preset.scratchpadFolderRequired, preset);
await logger.logEvent("tool-request", { tool: name, 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 = supportsProgress ? createProgressForwarder(ctx, name) : undefined;
return 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);
register("zoom-in", "final", ZoomInToolSchemaStrict, zoomInDescription, async () => (await import("../core/tools.js")).handleZoomIn, true);
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();