src / mcp / toolResults.ts
src / mcp / toolResults.ts
import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { getHealthyServerBaseUrl } from "../core-bundle.mjs";
import { buildOriginalLinks } from "./core-bundle.mjs";
import type { MaterializedResult } from "./resultMaterializer.js";
/** Video results register the last-frame PNG as the iN record; point the "Original" link at the actual .mov instead. */
function originalFileFor(scratchpadPath: string, result: MaterializedResult): { fileName: string; absolutePath: string } {
if (result.isVideo && result.videoFilename) {
return { fileName: result.videoFilename, absolutePath: path.join(scratchpadPath, result.videoFilename) };
}
return { fileName: result.filename, absolutePath: result.absolutePath };
}
function buildVideoNote(scratchpadPath: string, results: MaterializedResult[]): string {
const videoResults = results.filter((r) => r.isVideo && r.videoFilename);
if (videoResults.length === 0) return "";
return videoResults
.map((r) => {
const videoFilename = r.videoFilename as string;
return `\n\nVideo result(s) were generated: the preview above is a single still frame, not the video itself. To deliver the actual video to the user, call \`deliver_file_to_user\` on the video file directly: \n\n\`\`\`json\n{\n "source_path": "${videoFilename}",\n "target_path": "${videoFilename}",\n "overwrite": false\n}\n\`\`\`\n\nThis will copy the \`.mov\` to the project workspace. Write its Markdown link directly in the chat by using the absolute path to the \`.mov\` in the the project workspace. Refrain from renaming the file.`;
})
.join("");
}
/** How to present a result image to the user in chat: only when commenting on it, never unconditionally. */
function presentImageGuidance(displayPath: string, notation: string): string {
return `If you choose to comment on how well the result matches your prompt, present the image to the user in the chat as a reference.\n\nTo show an image to the user, first call \`attach_file(path="${displayPath}")\`, then write its Markdown link directly in the chat. Example: .`;
}
/** compact=true (COMPACT_TOOL_RESULT env var): the client already displays the base64 preview pushed above on its own, so neither the Original http/file links nor a show-the-image sentence are needed — merged directly into the produced-sentence instead. */
const COMPACT_PRESENTED_SENTENCE = "The result has been presented to the user. Comment on how well the result matches your prompt.";
/**
* Bionic-guided result text, following the same report-open -> agent-verify -> user-display
* -> follow-up-handle pattern as find-images-mcp-bridge's findImageToolResult() (see
* planning/bionic-mcp-architecture.md, "Resultatgesteuerte Agentenführung").
*
* Used only for 2+ results (MCP_MADE_FOR_BIONIC=true, the default) — see
* generateSingleImageToolResult() for the single-result case, which skips the HTML report entirely.
*/
export function generateImageToolResult(tool: "generate_image" | "upscale", reportPath: string, results: MaterializedResult[]): string {
const scratchpadPath = path.dirname(reportPath);
const reportFileName = path.basename(reportPath);
const notations = results.map((r) => r.notation).join(", ");
const viewPaths = results.map((r) => r.absolutePath);
const viewPayload = JSON.stringify({ paths: viewPaths, quality: "medium" }, null, 2);
const openPayload = JSON.stringify({ opened: true, url: pathToFileURL(reportPath).href }, null, 2);
const primaryNotation = results[0]?.notation ?? "i1";
const videoNote = buildVideoNote(scratchpadPath, results);
// The hard constraint leads (not "<tool> produced N file(s)..." first) — real Bionic traces
// (2026-10-09, find-images-mcp-bridge's identical pattern) showed the agent skipping straight to
// view_images and ignoring this instruction when it was buried as the 3rd sentence. Leading with
// it is the strongest lever this text alone has; not a guaranteed fix for LLM instruction-
// following, just the most prominent placement.
return `Immediately call "open_url_in_app_browser" with the JSON below — before any other tool call and before any user-facing response. ${tool} produced ${results.length} file(s): ${notations}; ${reportFileName} provides an overview.\n\n${openPayload}\n\nAfter the HTML report is open, use "view_images" to inspect the result(s) yourself. This tool does not display images to the user:\n\n${viewPayload}\n\n${presentImageGuidance(viewPaths[0] ?? "", primaryNotation)}${videoNote}\n\nRefer to a result in a subsequent generate_image or upscale call as its \`${primaryNotation}\` canvas/moodboard identifier.\n\nAll metadata for the generated file(s) is available in ${reportFileName}.`;
}
/**
* Single-result variant (MCP_MADE_FOR_BIONIC=true, the default): no HTML report is written or
* referenced. The in-app browser opens the generated file's own preview directly, and metadata
* is inlined 1:1 from the tool call's own summary object (same as the LM Studio plugin path's
* raw JSON.stringify(summary) content item), instead of pointing at a report file.
*/
export function generateSingleImageToolResult(tool: "generate_image" | "upscale", result: MaterializedResult, summary: Record<string, unknown> | undefined): string {
const scratchpadPath = path.dirname(result.absolutePath);
const viewPayload = JSON.stringify({ paths: [result.absolutePath], quality: "medium" }, null, 2);
const openPayload = JSON.stringify({ opened: true, url: pathToFileURL(result.absolutePath).href }, null, 2);
const videoNote = buildVideoNote(scratchpadPath, [result]);
// Same lead-with-the-hard-constraint fix as generateImageToolResult() above.
return `Immediately call "open_url_in_app_browser" with the JSON below to show the preview to the user — before any other tool call and before any user-facing response. ${tool} produced 1 file: ${result.notation}.\n\n${openPayload}\n\nAfter the preview is open, use "view_images" to inspect the result yourself. This tool does not display images to the user:\n\n${viewPayload}\n\n${presentImageGuidance(result.absolutePath, result.notation)}${videoNote}\n\nRefer to this result in a subsequent generate_image or upscale call as its \`${result.notation}\` canvas/moodboard identifier.\n\nMetadata for the generated file (from the tool call):\n\n${JSON.stringify(summary ?? {})}`;
}
/**
* Generic, client-agnostic tool result: an inline base64 preview image per result (only when
* includePreviewImage is true, i.e. MCP_MADE_FOR_BIONIC=false), each original file's http://
* link (scratchpadFolder-scoped, only when a local image server is reachable and scratchpadFolder
* is non-empty — see made-for-bionic-core's buildOriginalLinks()) and file:// link, the tool's
* own summary JSON forwarded verbatim, and a one-line usage hint. No HTML report, no
* host-specific tool-chaining instructions. Generic preset ONLY — unsloth has its own,
* independent buildUnslothToolResult() below.
*/
export async function buildToolResult(
tool: "generate_image" | "upscale",
scratchpadPath: string,
scratchpadFolder: string | undefined,
results: MaterializedResult[],
summaryText: string | undefined,
includePreviewImage: boolean,
compact: boolean = false
): Promise<{ content: unknown[] }> {
const content: unknown[] = [];
if (includePreviewImage) {
for (const result of results) {
if (!result.previewFilename) continue;
try {
const data = await fs.readFile(path.join(scratchpadPath, result.previewFilename));
content.push({ type: "image", fileName: result.previewFilename, mimeType: "image/jpeg", data: data.toString("base64") });
} catch {
// Preview file missing on disk — links + metadata below are still sent.
}
}
}
if (!compact) {
const httpBase = await getHealthyServerBaseUrl();
for (const result of results) {
const original = originalFileFor(scratchpadPath, result);
for (const link of buildOriginalLinks(httpBase, scratchpadFolder, original.fileName, original.absolutePath, result.notation)) {
content.push({ type: "text", text: link });
}
}
}
if (summaryText) {
content.push({ type: "text", text: summaryText });
}
const notations = results.map((r) => r.notation).join(", ");
const primaryNotation = results[0]?.notation ?? "i1";
const pronoun = results.length > 1 ? "them" : "it";
const producedSentence = compact
? `${tool} produced ${results.length} file(s): ${notations}. ${COMPACT_PRESENTED_SENTENCE}`
: `${tool} produced ${results.length} file(s): ${notations}. Use an appopriate method to show the image(s) ${pronoun} to the user.`;
content.push({
type: "text",
text: `${producedSentence}\n\nRefer to this result in a subsequent generate_image or upscale call as its \`${primaryNotation}\` canvas/moodboard identifier.`,
});
return { content };
}
/**
* Guidance for unsloth, worded for its auto-display + Markdown-embed model — unlike bionic's own
* presentImageGuidance() above, there is no attach_file tool call: the base64 preview is already
* shown inline by the tool result itself, so this only covers embedding a persistent Markdown
* link to the already-materialized preview file on top of that.
*/
function autoDisplayGuidance(previewFilename: string, notation: string): string {
return `If you choose to comment on how well the result matches your prompt, present the image to the user in the chat by writing its Markdown link directly in the chat. Example: .`;
}
/** Unsloth has no attach_file/deliver_file_to_user tool — the video itself is delivered as a plain (non-image) Markdown link to its already-materialized .mov file. */
function buildUnslothVideoNote(results: MaterializedResult[]): string {
const videoResults = results.filter((r) => r.isVideo && r.videoFilename);
if (videoResults.length === 0) return "";
return videoResults
.map((r) => `Video result(s) were generated: the preview above is a single still frame, not the video itself. To deliver the actual video to the user, deliver a clickable link to the video file: [${r.notation}](${r.videoFilename}).`)
.join("\n\n");
}
/**
* Unsloth's own, independent tool result — NOT a mode of buildToolResult() above. Paragraph
* order: image(s), produced-sentence, video note (if any), original link(s), autoDisplayGuidance(),
* refer-sentence, metadata label + JSON. Unsloth already auto-displays the base64 image(s) pushed
* above, so (unlike generic) there is no "show this to the user" sentence anywhere in this message.
*/
export async function buildUnslothToolResult(
tool: "generate_image" | "upscale",
scratchpadPath: string,
scratchpadFolder: string | undefined,
results: MaterializedResult[],
summaryText: string | undefined,
compact: boolean = false
): Promise<{ content: unknown[] }> {
const content: unknown[] = [];
for (const result of results) {
if (!result.previewFilename) continue;
try {
const data = await fs.readFile(path.join(scratchpadPath, result.previewFilename));
content.push({ type: "image", fileName: result.previewFilename, mimeType: "image/jpeg", data: data.toString("base64") });
} catch {
// Preview file missing on disk — links + metadata below are still sent.
}
}
const notations = results.map((r) => r.notation).join(", ");
const primaryNotation = results[0]?.notation ?? "i1";
content.push({
type: "text",
text: compact ? `${tool} produced ${results.length} file(s): ${notations}. ${COMPACT_PRESENTED_SENTENCE}` : `${tool} produced ${results.length} file(s): ${notations}.`,
});
// Video delivery note is unconditional of compact: the still-frame base64 preview above never covers the actual .mov.
const unslothVideoNote = buildUnslothVideoNote(results);
if (unslothVideoNote) {
content.push({ type: "text", text: unslothVideoNote });
}
if (!compact) {
const httpBase = await getHealthyServerBaseUrl();
for (const result of results) {
const original = originalFileFor(scratchpadPath, result);
for (const link of buildOriginalLinks(httpBase, scratchpadFolder, original.fileName, original.absolutePath, result.notation)) {
content.push({ type: "text", text: link });
}
}
content.push({ type: "text", text: autoDisplayGuidance(results[0]?.previewFilename ?? "", primaryNotation) });
}
content.push({
type: "text",
text: `Refer to this result in a subsequent generate_image or upscale call as its \`${primaryNotation}\` canvas/moodboard identifier.`,
});
if (summaryText) {
content.push({ type: "text", text: "Metadata for the generated file (from the tool call):" });
content.push({ type: "text", text: summaryText });
}
return { content };
}
import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { getHealthyServerBaseUrl } from "../core-bundle.mjs";
import { buildOriginalLinks } from "./core-bundle.mjs";
import type { MaterializedResult } from "./resultMaterializer.js";
/** Video results register the last-frame PNG as the iN record; point the "Original" link at the actual .mov instead. */
function originalFileFor(scratchpadPath: string, result: MaterializedResult): { fileName: string; absolutePath: string } {
if (result.isVideo && result.videoFilename) {
return { fileName: result.videoFilename, absolutePath: path.join(scratchpadPath, result.videoFilename) };
}
return { fileName: result.filename, absolutePath: result.absolutePath };
}
function buildVideoNote(scratchpadPath: string, results: MaterializedResult[]): string {
const videoResults = results.filter((r) => r.isVideo && r.videoFilename);
if (videoResults.length === 0) return "";
return videoResults
.map((r) => {
const videoFilename = r.videoFilename as string;
return `\n\nVideo result(s) were generated: the preview above is a single still frame, not the video itself. To deliver the actual video to the user, call \`deliver_file_to_user\` on the video file directly: \n\n\`\`\`json\n{\n "source_path": "${videoFilename}",\n "target_path": "${videoFilename}",\n "overwrite": false\n}\n\`\`\`\n\nThis will copy the \`.mov\` to the project workspace. Write its Markdown link directly in the chat by using the absolute path to the \`.mov\` in the the project workspace. Refrain from renaming the file.`;
})
.join("");
}
/** How to present a result image to the user in chat: only when commenting on it, never unconditionally. */
function presentImageGuidance(displayPath: string, notation: string): string {
return `If you choose to comment on how well the result matches your prompt, present the image to the user in the chat as a reference.\n\nTo show an image to the user, first call \`attach_file(path="${displayPath}")\`, then write its Markdown link directly in the chat. Example: .`;
}
/** compact=true (COMPACT_TOOL_RESULT env var): the client already displays the base64 preview pushed above on its own, so neither the Original http/file links nor a show-the-image sentence are needed — merged directly into the produced-sentence instead. */
const COMPACT_PRESENTED_SENTENCE = "The result has been presented to the user. Comment on how well the result matches your prompt.";
/**
* Bionic-guided result text, following the same report-open -> agent-verify -> user-display
* -> follow-up-handle pattern as find-images-mcp-bridge's findImageToolResult() (see
* planning/bionic-mcp-architecture.md, "Resultatgesteuerte Agentenführung").
*
* Used only for 2+ results (MCP_MADE_FOR_BIONIC=true, the default) — see
* generateSingleImageToolResult() for the single-result case, which skips the HTML report entirely.
*/
export function generateImageToolResult(tool: "generate_image" | "upscale", reportPath: string, results: MaterializedResult[]): string {
const scratchpadPath = path.dirname(reportPath);
const reportFileName = path.basename(reportPath);
const notations = results.map((r) => r.notation).join(", ");
const viewPaths = results.map((r) => r.absolutePath);
const viewPayload = JSON.stringify({ paths: viewPaths, quality: "medium" }, null, 2);
const openPayload = JSON.stringify({ opened: true, url: pathToFileURL(reportPath).href }, null, 2);
const primaryNotation = results[0]?.notation ?? "i1";
const videoNote = buildVideoNote(scratchpadPath, results);
// The hard constraint leads (not "<tool> produced N file(s)..." first) — real Bionic traces
// (2026-10-09, find-images-mcp-bridge's identical pattern) showed the agent skipping straight to
// view_images and ignoring this instruction when it was buried as the 3rd sentence. Leading with
// it is the strongest lever this text alone has; not a guaranteed fix for LLM instruction-
// following, just the most prominent placement.
return `Immediately call "open_url_in_app_browser" with the JSON below — before any other tool call and before any user-facing response. ${tool} produced ${results.length} file(s): ${notations}; ${reportFileName} provides an overview.\n\n${openPayload}\n\nAfter the HTML report is open, use "view_images" to inspect the result(s) yourself. This tool does not display images to the user:\n\n${viewPayload}\n\n${presentImageGuidance(viewPaths[0] ?? "", primaryNotation)}${videoNote}\n\nRefer to a result in a subsequent generate_image or upscale call as its \`${primaryNotation}\` canvas/moodboard identifier.\n\nAll metadata for the generated file(s) is available in ${reportFileName}.`;
}
/**
* Single-result variant (MCP_MADE_FOR_BIONIC=true, the default): no HTML report is written or
* referenced. The in-app browser opens the generated file's own preview directly, and metadata
* is inlined 1:1 from the tool call's own summary object (same as the LM Studio plugin path's
* raw JSON.stringify(summary) content item), instead of pointing at a report file.
*/
export function generateSingleImageToolResult(tool: "generate_image" | "upscale", result: MaterializedResult, summary: Record<string, unknown> | undefined): string {
const scratchpadPath = path.dirname(result.absolutePath);
const viewPayload = JSON.stringify({ paths: [result.absolutePath], quality: "medium" }, null, 2);
const openPayload = JSON.stringify({ opened: true, url: pathToFileURL(result.absolutePath).href }, null, 2);
const videoNote = buildVideoNote(scratchpadPath, [result]);
// Same lead-with-the-hard-constraint fix as generateImageToolResult() above.
return `Immediately call "open_url_in_app_browser" with the JSON below to show the preview to the user — before any other tool call and before any user-facing response. ${tool} produced 1 file: ${result.notation}.\n\n${openPayload}\n\nAfter the preview is open, use "view_images" to inspect the result yourself. This tool does not display images to the user:\n\n${viewPayload}\n\n${presentImageGuidance(result.absolutePath, result.notation)}${videoNote}\n\nRefer to this result in a subsequent generate_image or upscale call as its \`${result.notation}\` canvas/moodboard identifier.\n\nMetadata for the generated file (from the tool call):\n\n${JSON.stringify(summary ?? {})}`;
}
/**
* Generic, client-agnostic tool result: an inline base64 preview image per result (only when
* includePreviewImage is true, i.e. MCP_MADE_FOR_BIONIC=false), each original file's http://
* link (scratchpadFolder-scoped, only when a local image server is reachable and scratchpadFolder
* is non-empty — see made-for-bionic-core's buildOriginalLinks()) and file:// link, the tool's
* own summary JSON forwarded verbatim, and a one-line usage hint. No HTML report, no
* host-specific tool-chaining instructions. Generic preset ONLY — unsloth has its own,
* independent buildUnslothToolResult() below.
*/
export async function buildToolResult(
tool: "generate_image" | "upscale",
scratchpadPath: string,
scratchpadFolder: string | undefined,
results: MaterializedResult[],
summaryText: string | undefined,
includePreviewImage: boolean,
compact: boolean = false
): Promise<{ content: unknown[] }> {
const content: unknown[] = [];
if (includePreviewImage) {
for (const result of results) {
if (!result.previewFilename) continue;
try {
const data = await fs.readFile(path.join(scratchpadPath, result.previewFilename));
content.push({ type: "image", fileName: result.previewFilename, mimeType: "image/jpeg", data: data.toString("base64") });
} catch {
// Preview file missing on disk — links + metadata below are still sent.
}
}
}
if (!compact) {
const httpBase = await getHealthyServerBaseUrl();
for (const result of results) {
const original = originalFileFor(scratchpadPath, result);
for (const link of buildOriginalLinks(httpBase, scratchpadFolder, original.fileName, original.absolutePath, result.notation)) {
content.push({ type: "text", text: link });
}
}
}
if (summaryText) {
content.push({ type: "text", text: summaryText });
}
const notations = results.map((r) => r.notation).join(", ");
const primaryNotation = results[0]?.notation ?? "i1";
const pronoun = results.length > 1 ? "them" : "it";
const producedSentence = compact
? `${tool} produced ${results.length} file(s): ${notations}. ${COMPACT_PRESENTED_SENTENCE}`
: `${tool} produced ${results.length} file(s): ${notations}. Use an appopriate method to show the image(s) ${pronoun} to the user.`;
content.push({
type: "text",
text: `${producedSentence}\n\nRefer to this result in a subsequent generate_image or upscale call as its \`${primaryNotation}\` canvas/moodboard identifier.`,
});
return { content };
}
/**
* Guidance for unsloth, worded for its auto-display + Markdown-embed model — unlike bionic's own
* presentImageGuidance() above, there is no attach_file tool call: the base64 preview is already
* shown inline by the tool result itself, so this only covers embedding a persistent Markdown
* link to the already-materialized preview file on top of that.
*/
function autoDisplayGuidance(previewFilename: string, notation: string): string {
return `If you choose to comment on how well the result matches your prompt, present the image to the user in the chat by writing its Markdown link directly in the chat. Example: .`;
}
/** Unsloth has no attach_file/deliver_file_to_user tool — the video itself is delivered as a plain (non-image) Markdown link to its already-materialized .mov file. */
function buildUnslothVideoNote(results: MaterializedResult[]): string {
const videoResults = results.filter((r) => r.isVideo && r.videoFilename);
if (videoResults.length === 0) return "";
return videoResults
.map((r) => `Video result(s) were generated: the preview above is a single still frame, not the video itself. To deliver the actual video to the user, deliver a clickable link to the video file: [${r.notation}](${r.videoFilename}).`)
.join("\n\n");
}
/**
* Unsloth's own, independent tool result — NOT a mode of buildToolResult() above. Paragraph
* order: image(s), produced-sentence, video note (if any), original link(s), autoDisplayGuidance(),
* refer-sentence, metadata label + JSON. Unsloth already auto-displays the base64 image(s) pushed
* above, so (unlike generic) there is no "show this to the user" sentence anywhere in this message.
*/
export async function buildUnslothToolResult(
tool: "generate_image" | "upscale",
scratchpadPath: string,
scratchpadFolder: string | undefined,
results: MaterializedResult[],
summaryText: string | undefined,
compact: boolean = false
): Promise<{ content: unknown[] }> {
const content: unknown[] = [];
for (const result of results) {
if (!result.previewFilename) continue;
try {
const data = await fs.readFile(path.join(scratchpadPath, result.previewFilename));
content.push({ type: "image", fileName: result.previewFilename, mimeType: "image/jpeg", data: data.toString("base64") });
} catch {
// Preview file missing on disk — links + metadata below are still sent.
}
}
const notations = results.map((r) => r.notation).join(", ");
const primaryNotation = results[0]?.notation ?? "i1";
content.push({
type: "text",
text: compact ? `${tool} produced ${results.length} file(s): ${notations}. ${COMPACT_PRESENTED_SENTENCE}` : `${tool} produced ${results.length} file(s): ${notations}.`,
});
// Video delivery note is unconditional of compact: the still-frame base64 preview above never covers the actual .mov.
const unslothVideoNote = buildUnslothVideoNote(results);
if (unslothVideoNote) {
content.push({ type: "text", text: unslothVideoNote });
}
if (!compact) {
const httpBase = await getHealthyServerBaseUrl();
for (const result of results) {
const original = originalFileFor(scratchpadPath, result);
for (const link of buildOriginalLinks(httpBase, scratchpadFolder, original.fileName, original.absolutePath, result.notation)) {
content.push({ type: "text", text: link });
}
}
content.push({ type: "text", text: autoDisplayGuidance(results[0]?.previewFilename ?? "", primaryNotation) });
}
content.push({
type: "text",
text: `Refer to this result in a subsequent generate_image or upscale call as its \`${primaryNotation}\` canvas/moodboard identifier.`,
});
if (summaryText) {
content.push({ type: "text", text: "Metadata for the generated file (from the tool call):" });
content.push({ type: "text", text: summaryText });
}
return { content };
}