Forked from ceveyne/generate-image
Forked from ceveyne/generate-image
src / mcp / config.ts
src / mcp / config.ts
import { MADE_FOR_BIONIC_ENV_VAR, MADE_FOR_ENV_VAR, resolveMadeForEnv, optionalEnv, type McpResultPreset } from "./core-bundle.mjs";
import { getLogsDir, getPluginLogFilename, resolveLatestLmStudioLlamaServer, PROMPT_UPSAMPLING_MODEL_FAMILIES, PROMPT_UPSAMPLER_APIS, type PromptUpsamplerApi } from "../core-bundle.mjs";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// Matches toolsProvider.ts's localTimestamp() exactly, so entries from both
// entrypoints read consistently in the shared plugin log file.
function localTimestamp(): string {
try {
return new Date().toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
timeZoneName: "short",
});
} catch {
return new Date().toString();
}
}
/**
* Mirrors generate-image/src/config.ts's globalConfigSchematics fields — NOT
* draw-things-chat-core/src/config.ts (a different, orchestrator-only plugin
* config with overlapping field names). `unloadAgentModelDuringRender` is
* intentionally absent: it doesn't exist in generate-image's own config and
* unloadAgentModel() is never called from core/tools.ts.
*/
export type McpConfig = {
chatWorkingDirectories: string;
/** The preset resolved from MCP_MADE_FOR_BIONIC (primary, documented) or MCP_MADE_FOR (documented alternate selector for the unsloth/generic presets) — see made-for-bionic-core's presets/index.ts. */
preset: McpResultPreset;
/** = preset.includeBase64Preview. Kept as its own field only because process.env.PREVIEW_IN_CHAT (applyMcpConfig below) and core/tools.ts's own base64-in-return-value behavior are phrased in these "positive" terms. */
previewInChat: boolean;
/** Only affects buildToolResult/buildUnslothToolResult (toolResults.ts) — i.e. only reachable when preset.includeBase64Preview is true (generic or unsloth). No effect on the bionic preset's own generateImageToolResult/generateSingleImageToolResult. */
compactToolResult: boolean;
drawThingsHost: string;
drawThingsHttpPort: number;
drawThingsGrpcPort: number;
embedPngMetadata: boolean;
/** Rewrite the prompt with the capability's upsampling model before rendering. */
usePromptUpsampling: boolean;
/** LM Studio api/v1 server that provides the prompt upsampling model. */
promptUpsamplingApiBaseUrl: string;
promptUpsamplingApiKey: string;
/** Which adapter finds and loads the prompt upsampling model (VISION_API). */
promptUpsamplingApi: PromptUpsamplerApi;
/** The LLAMA_SERVER_* values are only read by the "llama-server" adapter (own standalone llama.cpp router). */
llamaServerBinary: string;
llamaServerPort: number;
llamaServerCtxSize: number;
llamaServerModelsMax: number;
llamaServerIdleTtlMinutes: number;
/** Raw value, may be "none" (disables custom configs) or empty. */
customConfigsPath: string;
httpServerPort: number;
/** Path to Unsloth Studio's local chat database (only read by the unsloth preset's beforeToolCall hook). */
clientDbLocation: string;
/** When true, MCP tool descriptions are minimized and point at the installed skill (skills/generate-image/); when false, the full inline description is restored. See tool-description-skill-concept.md. */
useSkills: boolean;
/** When true, the server keeps the local skill cache and the client-installed skill copy in sync automatically (see skillSync.ts); when false, nothing is ever written automatically — only the manual install_*_skills scripts apply updates. Never overwrites a user-modified installed copy either way. */
autoUpdateSkills: boolean;
};
/**
* CHAT_WORKING_DIRECTORIES has no single universal default — each client points its scratchpads
* somewhere different (bionic: LM Studio's own scratchpads root; unsloth: its sandbox dir under
* studio's own data folder; generic: the user's own Pictures folder, since a generic/third-party
* client isn't necessarily writing into an LM Studio-owned scratchpad).
*/
function defaultChatWorkingDirectories(preset: McpResultPreset): string {
if (preset.name === "unsloth") return path.join(os.homedir(), ".unsloth", "studio", "sandbox");
if (preset.name === "generic") return path.join(os.homedir(), "Pictures");
return path.join(os.homedir(), ".lmstudio", "scratchpads");
}
/** Keeps each preset's local HTTP server on its own port so bionic/unsloth/generic MCP servers can run side by side without colliding. */
function defaultHttpServerPort(preset: McpResultPreset): number {
if (preset.name === "unsloth") return 54780;
if (preset.name === "generic") return 54790;
return 54770;
}
/**
* Skill install state can't be verified by the server itself (it has no visibility into
* ~/.agents/skills or Bionic's installed-skills tracking), so this is a best-guess default, not a
* detection: bionic/unsloth are the two clients a skill install path currently exists for (Bionic's
* own "Install a skill" flow; scripts/install_unsloth_skills.sh), generic defaults to false since
* it's unverified whether a given generic client supports skills at all.
*/
function defaultUseSkills(preset: McpResultPreset): boolean {
return preset.name !== "generic";
}
/** Same default rationale as defaultUseSkills: bionic/unsloth have a working install path
* (and now a working self-heal path) for this content; generic stays opt-in. */
function defaultAutoUpdateSkills(preset: McpResultPreset): boolean {
return preset.name !== "generic";
}
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
if (value === undefined) return fallback;
return /^(1|true|yes)$/i.test(value.trim());
}
function parsePort(name: string, value: string | undefined, fallback: number): number {
if (value === undefined) return fallback;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`${name} must be a positive integer, got "${value}".`);
}
return parsed;
}
/**
* Reads `newName`, falling back to the deprecated, still-accepted-but-no-longer-documented
* `oldName` (a `GENERATE_IMAGE_`-prefixed leftover from before every MCP-only var here lost
* that redundant prefix) when `newName` is unset.
*/
function optionalEnvWithFallback(newName: string, oldName: string): string | undefined {
return optionalEnv(newName) ?? optionalEnv(oldName);
}
/**
* MCP_MADE_FOR (see made-for-bionic-core) takes precedence, then MCP_MADE_FOR_BIONIC. Falls back
* to the deprecated, generate-image-only GENERATE_IMAGE_PREVIEW_IN_CHAT when both are unset, same
* graceful pattern as every other renamed var here — INVERTED, since previewInChat=true meant the
* generic/base64 path, i.e. MCP_MADE_FOR_BIONIC=false. Only ever consulted as a value for
* resolveMadeForEnv()'s 2nd parameter, so MCP_MADE_FOR itself still always wins when set.
*/
function resolveGenerateImagePreset(): McpResultPreset {
const madeForRaw = optionalEnv(MADE_FOR_ENV_VAR);
let madeForBionicRaw = optionalEnv(MADE_FOR_BIONIC_ENV_VAR);
if (madeForBionicRaw === undefined) {
const legacyPreviewInChatRaw = optionalEnv("GENERATE_IMAGE_PREVIEW_IN_CHAT");
if (legacyPreviewInChatRaw !== undefined) madeForBionicRaw = parseBoolean(legacyPreviewInChatRaw, false) ? "false" : "true";
}
return resolveMadeForEnv(madeForRaw, madeForBionicRaw);
}
/** Per preset: where the prompt upsampling model is served and which adapter loads it. generic/unsloth default to their own llama-server, same as analyse-image. A llamaServerBinary of undefined means the highest installed LM Studio llama.cpp backend. */
const PROMPT_UPSAMPLING_PRESET_DEFAULTS: Record<McpResultPreset["name"], { apiBaseUrl: string; api: PromptUpsamplerApi; llamaServerBinary: string | undefined }> = {
bionic: {
apiBaseUrl: "http://127.0.0.1:1235/v1",
api: "bionic",
llamaServerBinary: undefined,
},
generic: {
apiBaseUrl: "http://127.0.0.1:1234/v1",
api: "llama-server",
llamaServerBinary: path.join(os.homedir(), ".lmstudio", "extensions", "backends", "qwen3-vl-embedding", "llama-server"),
},
unsloth: {
apiBaseUrl: "http://127.0.0.1:8888/v1",
api: "llama-server",
llamaServerBinary: path.join(os.homedir(), ".unsloth", "llama.cpp", "llama-server"),
},
};
/** Own port, so this router never adopts analyse-image's router (default 8099, other context size and model). */
const LLAMA_SERVER_PORT_DEFAULT = 8100;
const LLAMA_SERVER_MODELS_MAX_DEFAULT = 1;
const LLAMA_SERVER_IDLE_TTL_MINUTES_DEFAULT = 10;
/** The router loads every model with one --ctx-size, so the default is the largest context any upsampling model family asks for. */
function defaultLlamaServerCtxSize(): number {
return Math.max(...Object.values(PROMPT_UPSAMPLING_MODEL_FAMILIES).map((family) => family.contextLength));
}
function parseInteger(name: string, value: string | undefined, fallback: number, min: number): number {
if (value === undefined) return fallback;
const parsed = Number(value.trim());
if (!Number.isInteger(parsed) || parsed < min) {
throw new Error(`${name} must be an integer >= ${min}, got "${value}".`);
}
return parsed;
}
function resolvePromptUpsamplingApi(preset: McpResultPreset, raw: string | undefined): PromptUpsamplerApi {
if (raw === undefined) return PROMPT_UPSAMPLING_PRESET_DEFAULTS[preset.name].api;
const value = raw.trim().toLowerCase();
if (!(PROMPT_UPSAMPLER_APIS as readonly string[]).includes(value)) {
throw new Error(`VISION_API must be one of ${PROMPT_UPSAMPLER_APIS.join(", ")}, got "${raw}".`);
}
return value as PromptUpsamplerApi;
}
/** Every env var readMcpConfig() reads — used to log a complete, unfiltered snapshot. */
const MCP_ENV_VAR_NAMES = [
"CHAT_WORKING_DIRECTORIES",
MADE_FOR_BIONIC_ENV_VAR, // primary, documented selector (see README_MCP.md)
MADE_FOR_ENV_VAR, // documented alternate selector for the unsloth/generic presets (see README_MCP.md)
"GENERATE_IMAGE_PREVIEW_IN_CHAT", // deprecated, still accepted as an inverted fallback
"COMPACT_TOOL_RESULT",
"DRAW_THINGS_HOST",
"GENERATE_IMAGE_DRAW_THINGS_HOST", // deprecated, still accepted as a fallback
"DRAW_THINGS_HTTP_PORT",
"GENERATE_IMAGE_DRAW_THINGS_HTTP_PORT", // deprecated, still accepted as a fallback
"DRAW_THINGS_GRPC_PORT",
"GENERATE_IMAGE_DRAW_THINGS_GRPC_PORT", // deprecated, still accepted as a fallback
"EMBED_PNG_METADATA",
"GENERATE_IMAGE_EMBED_PNG_METADATA", // deprecated, still accepted as a fallback
"USE_PROMPT_UPSAMPLING",
"VISION_API",
"VISION_API_BASE_URL",
"VISION_API_KEY",
"LLAMA_SERVER_BINARY", // only read by the VISION_API="llama-server" adapter
"LLAMA_SERVER_PORT",
"LLAMA_SERVER_CTX_SIZE",
"LLAMA_SERVER_MODELS_MAX",
"LLAMA_SERVER_IDLE_TTL_MINUTES",
"DRAW_THINGS_CUSTOM_CONFIGS_PATH",
"GENERATE_IMAGE_CUSTOM_CONFIGS_PATH", // deprecated, still accepted as a fallback
"HTTP_SERVER_PORT",
"CLIENT_DB_LOCATION", // only read by the unsloth preset's beforeToolCall hook
"USE_SKILLS",
"AUTO_UPDATE_SKILLS",
] as const;
/**
* Raw process.env values for every var readMcpConfig() reads, exactly as received —
* unset stays `null` (never silently coalesced to a default), set-but-empty stays `""`.
* Callers should log this BEFORE readMcpConfig() so a required-env failure (e.g. an invalid
* DRAW_THINGS_GRPC_PORT) still leaves a full record of what was actually passed in.
*/
export function getRawEnvSnapshot(): Record<(typeof MCP_ENV_VAR_NAMES)[number], string | null> {
const snapshot = {} as Record<(typeof MCP_ENV_VAR_NAMES)[number], string | null>;
for (const name of MCP_ENV_VAR_NAMES) {
snapshot[name] = process.env[name] ?? null;
}
return snapshot;
}
export function readMcpConfig(): McpConfig {
const preset = resolveGenerateImagePreset();
return {
// Not a literal "~" string: nothing downstream expands tildes for this path (unlike
// customConfigsPath, which customConfigsLoader.ts explicitly expands), so the default is
// computed here via os.homedir() instead.
chatWorkingDirectories: optionalEnv("CHAT_WORKING_DIRECTORIES") ?? defaultChatWorkingDirectories(preset),
preset,
previewInChat: preset.includeBase64Preview,
compactToolResult: parseBoolean(optionalEnv("COMPACT_TOOL_RESULT"), false),
drawThingsHost: optionalEnvWithFallback("DRAW_THINGS_HOST", "GENERATE_IMAGE_DRAW_THINGS_HOST") ?? "127.0.0.1",
drawThingsHttpPort: parsePort("DRAW_THINGS_HTTP_PORT", optionalEnvWithFallback("DRAW_THINGS_HTTP_PORT", "GENERATE_IMAGE_DRAW_THINGS_HTTP_PORT"), 7860),
drawThingsGrpcPort: parsePort("DRAW_THINGS_GRPC_PORT", optionalEnvWithFallback("DRAW_THINGS_GRPC_PORT", "GENERATE_IMAGE_DRAW_THINGS_GRPC_PORT"), 7859),
embedPngMetadata: parseBoolean(optionalEnvWithFallback("EMBED_PNG_METADATA", "GENERATE_IMAGE_EMBED_PNG_METADATA"), true),
usePromptUpsampling: parseBoolean(optionalEnv("USE_PROMPT_UPSAMPLING"), true),
promptUpsamplingApiBaseUrl: optionalEnv("VISION_API_BASE_URL") ?? PROMPT_UPSAMPLING_PRESET_DEFAULTS[preset.name].apiBaseUrl,
promptUpsamplingApiKey: optionalEnv("VISION_API_KEY") ?? "",
promptUpsamplingApi: resolvePromptUpsamplingApi(preset, optionalEnv("VISION_API")),
llamaServerBinary: optionalEnv("LLAMA_SERVER_BINARY") ?? PROMPT_UPSAMPLING_PRESET_DEFAULTS[preset.name].llamaServerBinary ?? resolveLatestLmStudioLlamaServer() ?? "",
llamaServerPort: parsePort("LLAMA_SERVER_PORT", optionalEnv("LLAMA_SERVER_PORT"), LLAMA_SERVER_PORT_DEFAULT),
llamaServerCtxSize: parseInteger("LLAMA_SERVER_CTX_SIZE", optionalEnv("LLAMA_SERVER_CTX_SIZE"), defaultLlamaServerCtxSize(), 1),
llamaServerModelsMax: parseInteger("LLAMA_SERVER_MODELS_MAX", optionalEnv("LLAMA_SERVER_MODELS_MAX"), LLAMA_SERVER_MODELS_MAX_DEFAULT, 1),
llamaServerIdleTtlMinutes: parseInteger("LLAMA_SERVER_IDLE_TTL_MINUTES", optionalEnv("LLAMA_SERVER_IDLE_TTL_MINUTES"), LLAMA_SERVER_IDLE_TTL_MINUTES_DEFAULT, 0),
customConfigsPath:
optionalEnvWithFallback("DRAW_THINGS_CUSTOM_CONFIGS_PATH", "GENERATE_IMAGE_CUSTOM_CONFIGS_PATH") ??
"~/Library/Containers/com.liuliu.draw-things/Data/Documents/Models/custom_configs.json",
// Default mirrors src/config.ts's own HTTP_SERVER_PORT field default for bionic; unsloth/generic get their own port so multiple presets' MCP servers can run side by side. Plain HTTP_SERVER_PORT, no repo prefix — every bridge/preset must agree on the same port name.
httpServerPort: parsePort("HTTP_SERVER_PORT", optionalEnv("HTTP_SERVER_PORT"), defaultHttpServerPort(preset)),
clientDbLocation: optionalEnv("CLIENT_DB_LOCATION") ?? path.join(os.homedir(), ".unsloth", "studio", "studio.db"),
useSkills: parseBoolean(optionalEnv("USE_SKILLS"), defaultUseSkills(preset)),
autoUpdateSkills: parseBoolean(optionalEnv("AUTO_UPDATE_SKILLS"), defaultAutoUpdateSkills(preset)),
};
}
/** Sets the process.env values core/tools.ts reads directly (see getCurrentConnectionSettings, EMBED_PNG_METADATA check). */
export function applyMcpConfig(config: McpConfig): void {
process.env.PREVIEW_IN_CHAT = config.previewInChat ? "true" : "false";
process.env.DRAW_THINGS_HOST = config.drawThingsHost;
process.env.DRAW_THINGS_HTTP_PORT = String(config.drawThingsHttpPort);
process.env.DRAW_THINGS_GRPC_PORT = String(config.drawThingsGrpcPort);
process.env.EMBED_PNG_METADATA = config.embedPngMetadata ? "true" : "false";
process.env.LMSTUDIO_USE_PROMPT_UPSAMPLING = config.usePromptUpsampling ? "true" : "false";
process.env.LMSTUDIO_VISION_API_BASE_URL = config.promptUpsamplingApiBaseUrl;
process.env.LMSTUDIO_VISION_API_KEY = config.promptUpsamplingApiKey;
process.env.LMSTUDIO_VISION_API = config.promptUpsamplingApi;
process.env.LMSTUDIO_LLAMA_SERVER_BINARY = config.llamaServerBinary;
process.env.LMSTUDIO_LLAMA_SERVER_PORT = String(config.llamaServerPort);
process.env.LMSTUDIO_LLAMA_SERVER_CTX_SIZE = String(config.llamaServerCtxSize);
process.env.LMSTUDIO_LLAMA_SERVER_MODELS_MAX = String(config.llamaServerModelsMax);
process.env.LMSTUDIO_LLAMA_SERVER_IDLE_TTL_MINUTES = String(config.llamaServerIdleTtlMinutes);
process.env.HTTP_SERVER_PORT = String(config.httpServerPort);
}
function logPreflight(line: string): void {
try {
const logsDir = getLogsDir();
if (!fs.existsSync(logsDir)) fs.mkdirSync(logsDir, { recursive: true });
fs.appendFileSync(path.join(logsDir, getPluginLogFilename()), `${localTimestamp()} - ${line}\n`);
} catch {
// Never let logging itself break startup.
}
}
/**
* services/customConfigsLoader.ts caches the resolved path in module state, not
* an env var, and must be primed exactly once at startup (see its own comment
* "Called ONCE during main() startup"). Mirrors toolsProvider.ts's behavior,
* including its four-outcome logging into the shared plugin log file — without
* this, a failure here (e.g. Bionic's spawned node lacking read access to
* Draw Things' own sandboxed container) was previously completely silent.
*/
export type CustomConfigsOutcome =
| { status: "disabled" }
| { status: "loaded"; filePath: string; presetCount: number }
| { status: "not-available"; error: string };
export async function initCustomConfigs(customConfigsPath: string): Promise<CustomConfigsOutcome> {
const trimmed = customConfigsPath.trim();
if (!trimmed || trimmed.toLowerCase() === "none") {
logPreflight("Custom Configs: disabled");
return { status: "disabled" };
}
const { checkCustomConfigs, loadCustomConfigs } = await import("../services/customConfigsLoader.js");
const status = await checkCustomConfigs(trimmed);
if (status.available) {
logPreflight(`Custom Configs: found at ${status.filePath}`);
const presets = await loadCustomConfigs();
logPreflight(`Custom Configs: loaded ${presets.size} presets`);
return { status: "loaded", filePath: status.filePath!, presetCount: presets.size };
}
const error = status.error || "custom_configs.json not found";
logPreflight(`Custom Configs: not available (${error})`);
return { status: "not-available", error };
}
import { MADE_FOR_BIONIC_ENV_VAR, MADE_FOR_ENV_VAR, resolveMadeForEnv, optionalEnv, type McpResultPreset } from "./core-bundle.mjs";
import { getLogsDir, getPluginLogFilename, resolveLatestLmStudioLlamaServer, PROMPT_UPSAMPLING_MODEL_FAMILIES, PROMPT_UPSAMPLER_APIS, type PromptUpsamplerApi } from "../core-bundle.mjs";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// Matches toolsProvider.ts's localTimestamp() exactly, so entries from both
// entrypoints read consistently in the shared plugin log file.
function localTimestamp(): string {
try {
return new Date().toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
timeZoneName: "short",
});
} catch {
return new Date().toString();
}
}
/**
* Mirrors generate-image/src/config.ts's globalConfigSchematics fields — NOT
* draw-things-chat-core/src/config.ts (a different, orchestrator-only plugin
* config with overlapping field names). `unloadAgentModelDuringRender` is
* intentionally absent: it doesn't exist in generate-image's own config and
* unloadAgentModel() is never called from core/tools.ts.
*/
export type McpConfig = {
chatWorkingDirectories: string;
/** The preset resolved from MCP_MADE_FOR_BIONIC (primary, documented) or MCP_MADE_FOR (documented alternate selector for the unsloth/generic presets) — see made-for-bionic-core's presets/index.ts. */
preset: McpResultPreset;
/** = preset.includeBase64Preview. Kept as its own field only because process.env.PREVIEW_IN_CHAT (applyMcpConfig below) and core/tools.ts's own base64-in-return-value behavior are phrased in these "positive" terms. */
previewInChat: boolean;
/** Only affects buildToolResult/buildUnslothToolResult (toolResults.ts) — i.e. only reachable when preset.includeBase64Preview is true (generic or unsloth). No effect on the bionic preset's own generateImageToolResult/generateSingleImageToolResult. */
compactToolResult: boolean;
drawThingsHost: string;
drawThingsHttpPort: number;
drawThingsGrpcPort: number;
embedPngMetadata: boolean;
/** Rewrite the prompt with the capability's upsampling model before rendering. */
usePromptUpsampling: boolean;
/** LM Studio api/v1 server that provides the prompt upsampling model. */
promptUpsamplingApiBaseUrl: string;
promptUpsamplingApiKey: string;
/** Which adapter finds and loads the prompt upsampling model (VISION_API). */
promptUpsamplingApi: PromptUpsamplerApi;
/** The LLAMA_SERVER_* values are only read by the "llama-server" adapter (own standalone llama.cpp router). */
llamaServerBinary: string;
llamaServerPort: number;
llamaServerCtxSize: number;
llamaServerModelsMax: number;
llamaServerIdleTtlMinutes: number;
/** Raw value, may be "none" (disables custom configs) or empty. */
customConfigsPath: string;
httpServerPort: number;
/** Path to Unsloth Studio's local chat database (only read by the unsloth preset's beforeToolCall hook). */
clientDbLocation: string;
/** When true, MCP tool descriptions are minimized and point at the installed skill (skills/generate-image/); when false, the full inline description is restored. See tool-description-skill-concept.md. */
useSkills: boolean;
/** When true, the server keeps the local skill cache and the client-installed skill copy in sync automatically (see skillSync.ts); when false, nothing is ever written automatically — only the manual install_*_skills scripts apply updates. Never overwrites a user-modified installed copy either way. */
autoUpdateSkills: boolean;
};
/**
* CHAT_WORKING_DIRECTORIES has no single universal default — each client points its scratchpads
* somewhere different (bionic: LM Studio's own scratchpads root; unsloth: its sandbox dir under
* studio's own data folder; generic: the user's own Pictures folder, since a generic/third-party
* client isn't necessarily writing into an LM Studio-owned scratchpad).
*/
function defaultChatWorkingDirectories(preset: McpResultPreset): string {
if (preset.name === "unsloth") return path.join(os.homedir(), ".unsloth", "studio", "sandbox");
if (preset.name === "generic") return path.join(os.homedir(), "Pictures");
return path.join(os.homedir(), ".lmstudio", "scratchpads");
}
/** Keeps each preset's local HTTP server on its own port so bionic/unsloth/generic MCP servers can run side by side without colliding. */
function defaultHttpServerPort(preset: McpResultPreset): number {
if (preset.name === "unsloth") return 54780;
if (preset.name === "generic") return 54790;
return 54770;
}
/**
* Skill install state can't be verified by the server itself (it has no visibility into
* ~/.agents/skills or Bionic's installed-skills tracking), so this is a best-guess default, not a
* detection: bionic/unsloth are the two clients a skill install path currently exists for (Bionic's
* own "Install a skill" flow; scripts/install_unsloth_skills.sh), generic defaults to false since
* it's unverified whether a given generic client supports skills at all.
*/
function defaultUseSkills(preset: McpResultPreset): boolean {
return preset.name !== "generic";
}
/** Same default rationale as defaultUseSkills: bionic/unsloth have a working install path
* (and now a working self-heal path) for this content; generic stays opt-in. */
function defaultAutoUpdateSkills(preset: McpResultPreset): boolean {
return preset.name !== "generic";
}
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
if (value === undefined) return fallback;
return /^(1|true|yes)$/i.test(value.trim());
}
function parsePort(name: string, value: string | undefined, fallback: number): number {
if (value === undefined) return fallback;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`${name} must be a positive integer, got "${value}".`);
}
return parsed;
}
/**
* Reads `newName`, falling back to the deprecated, still-accepted-but-no-longer-documented
* `oldName` (a `GENERATE_IMAGE_`-prefixed leftover from before every MCP-only var here lost
* that redundant prefix) when `newName` is unset.
*/
function optionalEnvWithFallback(newName: string, oldName: string): string | undefined {
return optionalEnv(newName) ?? optionalEnv(oldName);
}
/**
* MCP_MADE_FOR (see made-for-bionic-core) takes precedence, then MCP_MADE_FOR_BIONIC. Falls back
* to the deprecated, generate-image-only GENERATE_IMAGE_PREVIEW_IN_CHAT when both are unset, same
* graceful pattern as every other renamed var here — INVERTED, since previewInChat=true meant the
* generic/base64 path, i.e. MCP_MADE_FOR_BIONIC=false. Only ever consulted as a value for
* resolveMadeForEnv()'s 2nd parameter, so MCP_MADE_FOR itself still always wins when set.
*/
function resolveGenerateImagePreset(): McpResultPreset {
const madeForRaw = optionalEnv(MADE_FOR_ENV_VAR);
let madeForBionicRaw = optionalEnv(MADE_FOR_BIONIC_ENV_VAR);
if (madeForBionicRaw === undefined) {
const legacyPreviewInChatRaw = optionalEnv("GENERATE_IMAGE_PREVIEW_IN_CHAT");
if (legacyPreviewInChatRaw !== undefined) madeForBionicRaw = parseBoolean(legacyPreviewInChatRaw, false) ? "false" : "true";
}
return resolveMadeForEnv(madeForRaw, madeForBionicRaw);
}
/** Per preset: where the prompt upsampling model is served and which adapter loads it. generic/unsloth default to their own llama-server, same as analyse-image. A llamaServerBinary of undefined means the highest installed LM Studio llama.cpp backend. */
const PROMPT_UPSAMPLING_PRESET_DEFAULTS: Record<McpResultPreset["name"], { apiBaseUrl: string; api: PromptUpsamplerApi; llamaServerBinary: string | undefined }> = {
bionic: {
apiBaseUrl: "http://127.0.0.1:1235/v1",
api: "bionic",
llamaServerBinary: undefined,
},
generic: {
apiBaseUrl: "http://127.0.0.1:1234/v1",
api: "llama-server",
llamaServerBinary: path.join(os.homedir(), ".lmstudio", "extensions", "backends", "qwen3-vl-embedding", "llama-server"),
},
unsloth: {
apiBaseUrl: "http://127.0.0.1:8888/v1",
api: "llama-server",
llamaServerBinary: path.join(os.homedir(), ".unsloth", "llama.cpp", "llama-server"),
},
};
/** Own port, so this router never adopts analyse-image's router (default 8099, other context size and model). */
const LLAMA_SERVER_PORT_DEFAULT = 8100;
const LLAMA_SERVER_MODELS_MAX_DEFAULT = 1;
const LLAMA_SERVER_IDLE_TTL_MINUTES_DEFAULT = 10;
/** The router loads every model with one --ctx-size, so the default is the largest context any upsampling model family asks for. */
function defaultLlamaServerCtxSize(): number {
return Math.max(...Object.values(PROMPT_UPSAMPLING_MODEL_FAMILIES).map((family) => family.contextLength));
}
function parseInteger(name: string, value: string | undefined, fallback: number, min: number): number {
if (value === undefined) return fallback;
const parsed = Number(value.trim());
if (!Number.isInteger(parsed) || parsed < min) {
throw new Error(`${name} must be an integer >= ${min}, got "${value}".`);
}
return parsed;
}
function resolvePromptUpsamplingApi(preset: McpResultPreset, raw: string | undefined): PromptUpsamplerApi {
if (raw === undefined) return PROMPT_UPSAMPLING_PRESET_DEFAULTS[preset.name].api;
const value = raw.trim().toLowerCase();
if (!(PROMPT_UPSAMPLER_APIS as readonly string[]).includes(value)) {
throw new Error(`VISION_API must be one of ${PROMPT_UPSAMPLER_APIS.join(", ")}, got "${raw}".`);
}
return value as PromptUpsamplerApi;
}
/** Every env var readMcpConfig() reads — used to log a complete, unfiltered snapshot. */
const MCP_ENV_VAR_NAMES = [
"CHAT_WORKING_DIRECTORIES",
MADE_FOR_BIONIC_ENV_VAR, // primary, documented selector (see README_MCP.md)
MADE_FOR_ENV_VAR, // documented alternate selector for the unsloth/generic presets (see README_MCP.md)
"GENERATE_IMAGE_PREVIEW_IN_CHAT", // deprecated, still accepted as an inverted fallback
"COMPACT_TOOL_RESULT",
"DRAW_THINGS_HOST",
"GENERATE_IMAGE_DRAW_THINGS_HOST", // deprecated, still accepted as a fallback
"DRAW_THINGS_HTTP_PORT",
"GENERATE_IMAGE_DRAW_THINGS_HTTP_PORT", // deprecated, still accepted as a fallback
"DRAW_THINGS_GRPC_PORT",
"GENERATE_IMAGE_DRAW_THINGS_GRPC_PORT", // deprecated, still accepted as a fallback
"EMBED_PNG_METADATA",
"GENERATE_IMAGE_EMBED_PNG_METADATA", // deprecated, still accepted as a fallback
"USE_PROMPT_UPSAMPLING",
"VISION_API",
"VISION_API_BASE_URL",
"VISION_API_KEY",
"LLAMA_SERVER_BINARY", // only read by the VISION_API="llama-server" adapter
"LLAMA_SERVER_PORT",
"LLAMA_SERVER_CTX_SIZE",
"LLAMA_SERVER_MODELS_MAX",
"LLAMA_SERVER_IDLE_TTL_MINUTES",
"DRAW_THINGS_CUSTOM_CONFIGS_PATH",
"GENERATE_IMAGE_CUSTOM_CONFIGS_PATH", // deprecated, still accepted as a fallback
"HTTP_SERVER_PORT",
"CLIENT_DB_LOCATION", // only read by the unsloth preset's beforeToolCall hook
"USE_SKILLS",
"AUTO_UPDATE_SKILLS",
] as const;
/**
* Raw process.env values for every var readMcpConfig() reads, exactly as received —
* unset stays `null` (never silently coalesced to a default), set-but-empty stays `""`.
* Callers should log this BEFORE readMcpConfig() so a required-env failure (e.g. an invalid
* DRAW_THINGS_GRPC_PORT) still leaves a full record of what was actually passed in.
*/
export function getRawEnvSnapshot(): Record<(typeof MCP_ENV_VAR_NAMES)[number], string | null> {
const snapshot = {} as Record<(typeof MCP_ENV_VAR_NAMES)[number], string | null>;
for (const name of MCP_ENV_VAR_NAMES) {
snapshot[name] = process.env[name] ?? null;
}
return snapshot;
}
export function readMcpConfig(): McpConfig {
const preset = resolveGenerateImagePreset();
return {
// Not a literal "~" string: nothing downstream expands tildes for this path (unlike
// customConfigsPath, which customConfigsLoader.ts explicitly expands), so the default is
// computed here via os.homedir() instead.
chatWorkingDirectories: optionalEnv("CHAT_WORKING_DIRECTORIES") ?? defaultChatWorkingDirectories(preset),
preset,
previewInChat: preset.includeBase64Preview,
compactToolResult: parseBoolean(optionalEnv("COMPACT_TOOL_RESULT"), false),
drawThingsHost: optionalEnvWithFallback("DRAW_THINGS_HOST", "GENERATE_IMAGE_DRAW_THINGS_HOST") ?? "127.0.0.1",
drawThingsHttpPort: parsePort("DRAW_THINGS_HTTP_PORT", optionalEnvWithFallback("DRAW_THINGS_HTTP_PORT", "GENERATE_IMAGE_DRAW_THINGS_HTTP_PORT"), 7860),
drawThingsGrpcPort: parsePort("DRAW_THINGS_GRPC_PORT", optionalEnvWithFallback("DRAW_THINGS_GRPC_PORT", "GENERATE_IMAGE_DRAW_THINGS_GRPC_PORT"), 7859),
embedPngMetadata: parseBoolean(optionalEnvWithFallback("EMBED_PNG_METADATA", "GENERATE_IMAGE_EMBED_PNG_METADATA"), true),
usePromptUpsampling: parseBoolean(optionalEnv("USE_PROMPT_UPSAMPLING"), true),
promptUpsamplingApiBaseUrl: optionalEnv("VISION_API_BASE_URL") ?? PROMPT_UPSAMPLING_PRESET_DEFAULTS[preset.name].apiBaseUrl,
promptUpsamplingApiKey: optionalEnv("VISION_API_KEY") ?? "",
promptUpsamplingApi: resolvePromptUpsamplingApi(preset, optionalEnv("VISION_API")),
llamaServerBinary: optionalEnv("LLAMA_SERVER_BINARY") ?? PROMPT_UPSAMPLING_PRESET_DEFAULTS[preset.name].llamaServerBinary ?? resolveLatestLmStudioLlamaServer() ?? "",
llamaServerPort: parsePort("LLAMA_SERVER_PORT", optionalEnv("LLAMA_SERVER_PORT"), LLAMA_SERVER_PORT_DEFAULT),
llamaServerCtxSize: parseInteger("LLAMA_SERVER_CTX_SIZE", optionalEnv("LLAMA_SERVER_CTX_SIZE"), defaultLlamaServerCtxSize(), 1),
llamaServerModelsMax: parseInteger("LLAMA_SERVER_MODELS_MAX", optionalEnv("LLAMA_SERVER_MODELS_MAX"), LLAMA_SERVER_MODELS_MAX_DEFAULT, 1),
llamaServerIdleTtlMinutes: parseInteger("LLAMA_SERVER_IDLE_TTL_MINUTES", optionalEnv("LLAMA_SERVER_IDLE_TTL_MINUTES"), LLAMA_SERVER_IDLE_TTL_MINUTES_DEFAULT, 0),
customConfigsPath:
optionalEnvWithFallback("DRAW_THINGS_CUSTOM_CONFIGS_PATH", "GENERATE_IMAGE_CUSTOM_CONFIGS_PATH") ??
"~/Library/Containers/com.liuliu.draw-things/Data/Documents/Models/custom_configs.json",
// Default mirrors src/config.ts's own HTTP_SERVER_PORT field default for bionic; unsloth/generic get their own port so multiple presets' MCP servers can run side by side. Plain HTTP_SERVER_PORT, no repo prefix — every bridge/preset must agree on the same port name.
httpServerPort: parsePort("HTTP_SERVER_PORT", optionalEnv("HTTP_SERVER_PORT"), defaultHttpServerPort(preset)),
clientDbLocation: optionalEnv("CLIENT_DB_LOCATION") ?? path.join(os.homedir(), ".unsloth", "studio", "studio.db"),
useSkills: parseBoolean(optionalEnv("USE_SKILLS"), defaultUseSkills(preset)),
autoUpdateSkills: parseBoolean(optionalEnv("AUTO_UPDATE_SKILLS"), defaultAutoUpdateSkills(preset)),
};
}
/** Sets the process.env values core/tools.ts reads directly (see getCurrentConnectionSettings, EMBED_PNG_METADATA check). */
export function applyMcpConfig(config: McpConfig): void {
process.env.PREVIEW_IN_CHAT = config.previewInChat ? "true" : "false";
process.env.DRAW_THINGS_HOST = config.drawThingsHost;
process.env.DRAW_THINGS_HTTP_PORT = String(config.drawThingsHttpPort);
process.env.DRAW_THINGS_GRPC_PORT = String(config.drawThingsGrpcPort);
process.env.EMBED_PNG_METADATA = config.embedPngMetadata ? "true" : "false";
process.env.LMSTUDIO_USE_PROMPT_UPSAMPLING = config.usePromptUpsampling ? "true" : "false";
process.env.LMSTUDIO_VISION_API_BASE_URL = config.promptUpsamplingApiBaseUrl;
process.env.LMSTUDIO_VISION_API_KEY = config.promptUpsamplingApiKey;
process.env.LMSTUDIO_VISION_API = config.promptUpsamplingApi;
process.env.LMSTUDIO_LLAMA_SERVER_BINARY = config.llamaServerBinary;
process.env.LMSTUDIO_LLAMA_SERVER_PORT = String(config.llamaServerPort);
process.env.LMSTUDIO_LLAMA_SERVER_CTX_SIZE = String(config.llamaServerCtxSize);
process.env.LMSTUDIO_LLAMA_SERVER_MODELS_MAX = String(config.llamaServerModelsMax);
process.env.LMSTUDIO_LLAMA_SERVER_IDLE_TTL_MINUTES = String(config.llamaServerIdleTtlMinutes);
process.env.HTTP_SERVER_PORT = String(config.httpServerPort);
}
function logPreflight(line: string): void {
try {
const logsDir = getLogsDir();
if (!fs.existsSync(logsDir)) fs.mkdirSync(logsDir, { recursive: true });
fs.appendFileSync(path.join(logsDir, getPluginLogFilename()), `${localTimestamp()} - ${line}\n`);
} catch {
// Never let logging itself break startup.
}
}
/**
* services/customConfigsLoader.ts caches the resolved path in module state, not
* an env var, and must be primed exactly once at startup (see its own comment
* "Called ONCE during main() startup"). Mirrors toolsProvider.ts's behavior,
* including its four-outcome logging into the shared plugin log file — without
* this, a failure here (e.g. Bionic's spawned node lacking read access to
* Draw Things' own sandboxed container) was previously completely silent.
*/
export type CustomConfigsOutcome =
| { status: "disabled" }
| { status: "loaded"; filePath: string; presetCount: number }
| { status: "not-available"; error: string };
export async function initCustomConfigs(customConfigsPath: string): Promise<CustomConfigsOutcome> {
const trimmed = customConfigsPath.trim();
if (!trimmed || trimmed.toLowerCase() === "none") {
logPreflight("Custom Configs: disabled");
return { status: "disabled" };
}
const { checkCustomConfigs, loadCustomConfigs } = await import("../services/customConfigsLoader.js");
const status = await checkCustomConfigs(trimmed);
if (status.available) {
logPreflight(`Custom Configs: found at ${status.filePath}`);
const presets = await loadCustomConfigs();
logPreflight(`Custom Configs: loaded ${presets.size} presets`);
return { status: "loaded", filePath: status.filePath!, presetCount: presets.size };
}
const error = status.error || "custom_configs.json not found";
logPreflight(`Custom Configs: not available (${error})`);
return { status: "not-available", error };
}