src / mcp / config.ts
src / mcp / config.ts
import { optionalEnv, resolveMadeForEnv, type McpResultPreset } from "./core-bundle.mjs";
import { getLogsDir, getPluginLogFilename } 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 process-image/src/config.ts's globalConfigSchematics fields. No
* GENERATE_IMAGE_*-style legacy fallback vars are read here — process-image
* never had any (see planning/process-image-mcp-plan.md, "Konfiguration").
*/
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;
drawThingsHost: string;
drawThingsHttpPort: number;
drawThingsGrpcPort: number;
embedPngMetadata: boolean;
/** 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;
};
/**
* 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. Same scheme as every other bridge — all must agree on the same port per preset. */
function defaultHttpServerPort(preset: McpResultPreset): number {
if (preset.name === "unsloth") return 54780;
if (preset.name === "generic") return 54790;
return 54770;
}
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;
}
/** Every env var readMcpConfig() reads — used to log a complete, unfiltered snapshot. */
const MCP_ENV_VAR_NAMES = [
"CHAT_WORKING_DIRECTORIES",
"MCP_MADE_FOR_BIONIC", // primary, documented selector (see README_MCP.md)
"MCP_MADE_FOR", // documented alternate selector for the unsloth/generic presets (see README_MCP.md)
"DRAW_THINGS_HOST",
"DRAW_THINGS_HTTP_PORT",
"DRAW_THINGS_GRPC_PORT",
"EMBED_PNG_METADATA",
"DRAW_THINGS_CUSTOM_CONFIGS_PATH",
"HTTP_SERVER_PORT",
"CLIENT_DB_LOCATION", // only read by the unsloth preset's beforeToolCall hook
] 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 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 = resolveMadeForEnv(optionalEnv("MCP_MADE_FOR"), optionalEnv("MCP_MADE_FOR_BIONIC"));
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,
drawThingsHost: optionalEnv("DRAW_THINGS_HOST") ?? "127.0.0.1",
drawThingsHttpPort: parsePort("DRAW_THINGS_HTTP_PORT", optionalEnv("DRAW_THINGS_HTTP_PORT"), 7860),
drawThingsGrpcPort: parsePort("DRAW_THINGS_GRPC_PORT", optionalEnv("DRAW_THINGS_GRPC_PORT"), 7859),
embedPngMetadata: parseBoolean(optionalEnv("EMBED_PNG_METADATA"), true),
customConfigsPath: optionalEnv("DRAW_THINGS_CUSTOM_CONFIGS_PATH") ?? "~/Library/Containers/com.liuliu.draw-things/Data/Documents/Models/custom_configs.json",
// Plain HTTP_SERVER_PORT, no repo prefix — every bridge/preset must agree on the same port name and the same preset-based default.
httpServerPort: parsePort("HTTP_SERVER_PORT", optionalEnv("HTTP_SERVER_PORT"), defaultHttpServerPort(preset)),
clientDbLocation: optionalEnv("CLIENT_DB_LOCATION") ?? path.join(os.homedir(), ".unsloth", "studio", "studio.db"),
};
}
/** 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.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.
*/
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 { optionalEnv, resolveMadeForEnv, type McpResultPreset } from "./core-bundle.mjs";
import { getLogsDir, getPluginLogFilename } 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 process-image/src/config.ts's globalConfigSchematics fields. No
* GENERATE_IMAGE_*-style legacy fallback vars are read here — process-image
* never had any (see planning/process-image-mcp-plan.md, "Konfiguration").
*/
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;
drawThingsHost: string;
drawThingsHttpPort: number;
drawThingsGrpcPort: number;
embedPngMetadata: boolean;
/** 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;
};
/**
* 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. Same scheme as every other bridge — all must agree on the same port per preset. */
function defaultHttpServerPort(preset: McpResultPreset): number {
if (preset.name === "unsloth") return 54780;
if (preset.name === "generic") return 54790;
return 54770;
}
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;
}
/** Every env var readMcpConfig() reads — used to log a complete, unfiltered snapshot. */
const MCP_ENV_VAR_NAMES = [
"CHAT_WORKING_DIRECTORIES",
"MCP_MADE_FOR_BIONIC", // primary, documented selector (see README_MCP.md)
"MCP_MADE_FOR", // documented alternate selector for the unsloth/generic presets (see README_MCP.md)
"DRAW_THINGS_HOST",
"DRAW_THINGS_HTTP_PORT",
"DRAW_THINGS_GRPC_PORT",
"EMBED_PNG_METADATA",
"DRAW_THINGS_CUSTOM_CONFIGS_PATH",
"HTTP_SERVER_PORT",
"CLIENT_DB_LOCATION", // only read by the unsloth preset's beforeToolCall hook
] 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 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 = resolveMadeForEnv(optionalEnv("MCP_MADE_FOR"), optionalEnv("MCP_MADE_FOR_BIONIC"));
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,
drawThingsHost: optionalEnv("DRAW_THINGS_HOST") ?? "127.0.0.1",
drawThingsHttpPort: parsePort("DRAW_THINGS_HTTP_PORT", optionalEnv("DRAW_THINGS_HTTP_PORT"), 7860),
drawThingsGrpcPort: parsePort("DRAW_THINGS_GRPC_PORT", optionalEnv("DRAW_THINGS_GRPC_PORT"), 7859),
embedPngMetadata: parseBoolean(optionalEnv("EMBED_PNG_METADATA"), true),
customConfigsPath: optionalEnv("DRAW_THINGS_CUSTOM_CONFIGS_PATH") ?? "~/Library/Containers/com.liuliu.draw-things/Data/Documents/Models/custom_configs.json",
// Plain HTTP_SERVER_PORT, no repo prefix — every bridge/preset must agree on the same port name and the same preset-based default.
httpServerPort: parsePort("HTTP_SERVER_PORT", optionalEnv("HTTP_SERVER_PORT"), defaultHttpServerPort(preset)),
clientDbLocation: optionalEnv("CLIENT_DB_LOCATION") ?? path.join(os.homedir(), ".unsloth", "studio", "studio.db"),
};
}
/** 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.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.
*/
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 };
}