dist-mcp / mcp / config.js
dist-mcp / mcp / config.js
import { optionalEnv, resolveMadeForEnv } 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() {
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();
}
}
/**
* One self-contained defaults record per adapter (bionic/generic/unsloth) — no per-field
* if/else-on-preset.name branching scattered across separate functions. Each adapter owns its
* own scratchpad root, Vision API server default, model key default, and default VISION_API
* strand (NOT always its own name — generic/unsloth default to "llama-server" since their native
* load-then-verify strategies proved unreliable in practice, see
* made-for-bionic/planning/made-for-preset-rollout-plan.md). Independently overridable per call
* via VISION_API — see resolveVisionApi() below.
*/
const PRESET_DEFAULTS = {
bionic: {
chatWorkingDirectories: path.join(os.homedir(), ".lmstudio", "scratchpads"),
visionApiBaseUrl: "http://127.0.0.1:1234/v1",
qwen3VlModel: "qwen/qwen3-vl-8b",
llamaServerBinary: path.join(os.homedir(), ".lmstudio", "extensions", "backends", "llama.cpp-mac-arm64-apple-metal-advsimd-2.48.0", "llama-server"),
visionApi: "bionic",
},
generic: {
chatWorkingDirectories: path.join(os.homedir(), "Pictures"),
visionApiBaseUrl: "http://127.0.0.1:1234/v1",
qwen3VlModel: "qwen/qwen3-vl-8b",
llamaServerBinary: path.join(os.homedir(), ".lmstudio", "extensions", "backends", "qwen3-vl-embedding", "llama-server"),
visionApi: "llama-server",
},
unsloth: {
chatWorkingDirectories: path.join(os.homedir(), ".unsloth", "studio", "sandbox"),
visionApiBaseUrl: "http://127.0.0.1:8888/v1",
qwen3VlModel: "unsloth/Qwen3-VL-8B-Instruct-GGUF",
llamaServerBinary: path.join(os.homedir(), ".unsloth", "llama.cpp", "llama-server"),
visionApi: "llama-server",
},
};
function parseBoolean(value, fallback) {
if (value === undefined)
return fallback;
return /^(1|true|yes)$/i.test(value.trim());
}
function parseNumber(value, fallback) {
if (value === undefined)
return fallback;
const parsed = Number(value.trim());
return Number.isFinite(parsed) ? parsed : fallback;
}
function parsePort(name, value, fallback) {
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;
}
/** Same unified scheme as every other bridge (generate-image, process-image): each preset gets its own fixed port so bionic/unsloth/generic MCP servers can run side by side without colliding. */
function defaultHttpServerPort(preset) {
if (preset.name === "unsloth")
return 54780;
if (preset.name === "generic")
return 54790;
return 54770;
}
/** Not preset-dependent -- same default for bionic/generic/unsloth, see src/config.ts's matching llamaServer* defaults. */
const LLAMA_SERVER_PORT_DEFAULT = 8099;
const LLAMA_SERVER_CTX_SIZE_DEFAULT = 8192;
/** analyse-image only ever requests 1 model through the router at a time -- kept adjustable (not hardcoded to 1) for reuse elsewhere, should the need for >1 concurrent model ever arise. */
const LLAMA_SERVER_MODELS_MAX_DEFAULT = 1;
const LLAMA_SERVER_IDLE_TTL_MINUTES_DEFAULT = 10;
/** Every env var readMcpConfig() reads — used to log a complete, unfiltered snapshot. */
const MCP_ENV_VAR_NAMES = [
"CHAT_WORKING_DIRECTORIES",
"MCP_MADE_FOR_BIONIC", // legacy, documented selector (see README_MCP.md)
"MCP_MADE_FOR", // documented alternate selector for the unsloth/generic presets (see README_MCP.md)
"VISION_API", // overrides which adapter's model-loading methods to use; defaults to MCP_MADE_FOR's preset name
"VISION_API_BASE_URL",
"VISION_API_KEY",
"QWEN3_VL_MODEL",
"LLAMA_SERVER_BINARY", // only read by the VISION_API="llama-server" adapter
"LLAMA_SERVER_PORT", // only read by the VISION_API="llama-server" adapter
"LLAMA_SERVER_CTX_SIZE", // only read by the VISION_API="llama-server" adapter
"LLAMA_SERVER_MODELS_MAX", // only read by the VISION_API="llama-server" adapter
"LLAMA_SERVER_IDLE_TTL_MINUTES", // only read by the VISION_API="llama-server" adapter
"EMBED_PNG_METADATA",
"INCLUDE_GENERATION_METADATA",
"CLIENT_DB_LOCATION", // only read by the unsloth preset's beforeToolCall hook
"HTTP_SERVER_PORT",
];
/**
* 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() {
const snapshot = {};
for (const name of MCP_ENV_VAR_NAMES) {
snapshot[name] = process.env[name] ?? null;
}
return snapshot;
}
const VISION_API_NAMES = ["bionic", "generic", "unsloth", "llama-server"];
/** VISION_API overrides which adapter's model-loading methods to use; defaults to each preset's own PRESET_DEFAULTS.visionApi (NOT always preset.name — generic/unsloth now default to "llama-server", see PRESET_DEFAULTS). Falls back to the same default on an unset or unrecognized value — never throws. */
function resolveVisionApi(preset, raw) {
const trimmed = (raw ?? "").trim().toLowerCase();
return VISION_API_NAMES.includes(trimmed) ? trimmed : PRESET_DEFAULTS[preset.name].visionApi;
}
export function readMcpConfig() {
const preset = resolveMadeForEnv(optionalEnv("MCP_MADE_FOR"), optionalEnv("MCP_MADE_FOR_BIONIC"));
const defaults = PRESET_DEFAULTS[preset.name];
return {
chatWorkingDirectories: optionalEnv("CHAT_WORKING_DIRECTORIES") ?? defaults.chatWorkingDirectories,
preset,
visionApiBaseUrl: optionalEnv("VISION_API_BASE_URL") ?? defaults.visionApiBaseUrl,
visionApiKey: optionalEnv("VISION_API_KEY") ?? "",
qwen3VlModel: optionalEnv("QWEN3_VL_MODEL") ?? defaults.qwen3VlModel,
embedPngMetadata: parseBoolean(optionalEnv("EMBED_PNG_METADATA"), true),
includeGenerationMetadata: parseBoolean(optionalEnv("INCLUDE_GENERATION_METADATA"), true),
clientDbLocation: optionalEnv("CLIENT_DB_LOCATION") ?? path.join(os.homedir(), ".unsloth", "studio", "studio.db"),
visionApi: resolveVisionApi(preset, optionalEnv("VISION_API")),
llamaServerBinary: optionalEnv("LLAMA_SERVER_BINARY") ?? defaults.llamaServerBinary,
llamaServerPort: parseNumber(optionalEnv("LLAMA_SERVER_PORT"), LLAMA_SERVER_PORT_DEFAULT),
llamaServerCtxSize: parseNumber(optionalEnv("LLAMA_SERVER_CTX_SIZE"), LLAMA_SERVER_CTX_SIZE_DEFAULT),
llamaServerModelsMax: parseNumber(optionalEnv("LLAMA_SERVER_MODELS_MAX"), LLAMA_SERVER_MODELS_MAX_DEFAULT),
llamaServerIdleTtlMinutes: parseNumber(optionalEnv("LLAMA_SERVER_IDLE_TTL_MINUTES"), LLAMA_SERVER_IDLE_TTL_MINUTES_DEFAULT),
httpServerPort: parsePort("HTTP_SERVER_PORT", optionalEnv("HTTP_SERVER_PORT"), defaultHttpServerPort(preset)),
};
}
/** Sets the process.env values core/tools.ts reads directly (see handleAnalyseImage/handleDetectObject/handleAnnotateImage). */
export function applyMcpConfig(config) {
process.env.LMSTUDIO_VISION_API_BASE_URL = config.visionApiBaseUrl;
process.env.LMSTUDIO_VISION_API_KEY = config.visionApiKey;
process.env.LMSTUDIO_VISION_MODEL_KEY = config.qwen3VlModel;
process.env.EMBED_PNG_METADATA = config.embedPngMetadata ? "true" : "false";
process.env.INCLUDE_GENERATION_METADATA = config.includeGenerationMetadata ? "true" : "false";
process.env.LMSTUDIO_VISION_API = config.visionApi;
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);
}
export function logPreflight(line) {
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.
}
}
import { optionalEnv, resolveMadeForEnv } 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() {
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();
}
}
/**
* One self-contained defaults record per adapter (bionic/generic/unsloth) — no per-field
* if/else-on-preset.name branching scattered across separate functions. Each adapter owns its
* own scratchpad root, Vision API server default, model key default, and default VISION_API
* strand (NOT always its own name — generic/unsloth default to "llama-server" since their native
* load-then-verify strategies proved unreliable in practice, see
* made-for-bionic/planning/made-for-preset-rollout-plan.md). Independently overridable per call
* via VISION_API — see resolveVisionApi() below.
*/
const PRESET_DEFAULTS = {
bionic: {
chatWorkingDirectories: path.join(os.homedir(), ".lmstudio", "scratchpads"),
visionApiBaseUrl: "http://127.0.0.1:1234/v1",
qwen3VlModel: "qwen/qwen3-vl-8b",
llamaServerBinary: path.join(os.homedir(), ".lmstudio", "extensions", "backends", "llama.cpp-mac-arm64-apple-metal-advsimd-2.48.0", "llama-server"),
visionApi: "bionic",
},
generic: {
chatWorkingDirectories: path.join(os.homedir(), "Pictures"),
visionApiBaseUrl: "http://127.0.0.1:1234/v1",
qwen3VlModel: "qwen/qwen3-vl-8b",
llamaServerBinary: path.join(os.homedir(), ".lmstudio", "extensions", "backends", "qwen3-vl-embedding", "llama-server"),
visionApi: "llama-server",
},
unsloth: {
chatWorkingDirectories: path.join(os.homedir(), ".unsloth", "studio", "sandbox"),
visionApiBaseUrl: "http://127.0.0.1:8888/v1",
qwen3VlModel: "unsloth/Qwen3-VL-8B-Instruct-GGUF",
llamaServerBinary: path.join(os.homedir(), ".unsloth", "llama.cpp", "llama-server"),
visionApi: "llama-server",
},
};
function parseBoolean(value, fallback) {
if (value === undefined)
return fallback;
return /^(1|true|yes)$/i.test(value.trim());
}
function parseNumber(value, fallback) {
if (value === undefined)
return fallback;
const parsed = Number(value.trim());
return Number.isFinite(parsed) ? parsed : fallback;
}
function parsePort(name, value, fallback) {
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;
}
/** Same unified scheme as every other bridge (generate-image, process-image): each preset gets its own fixed port so bionic/unsloth/generic MCP servers can run side by side without colliding. */
function defaultHttpServerPort(preset) {
if (preset.name === "unsloth")
return 54780;
if (preset.name === "generic")
return 54790;
return 54770;
}
/** Not preset-dependent -- same default for bionic/generic/unsloth, see src/config.ts's matching llamaServer* defaults. */
const LLAMA_SERVER_PORT_DEFAULT = 8099;
const LLAMA_SERVER_CTX_SIZE_DEFAULT = 8192;
/** analyse-image only ever requests 1 model through the router at a time -- kept adjustable (not hardcoded to 1) for reuse elsewhere, should the need for >1 concurrent model ever arise. */
const LLAMA_SERVER_MODELS_MAX_DEFAULT = 1;
const LLAMA_SERVER_IDLE_TTL_MINUTES_DEFAULT = 10;
/** Every env var readMcpConfig() reads — used to log a complete, unfiltered snapshot. */
const MCP_ENV_VAR_NAMES = [
"CHAT_WORKING_DIRECTORIES",
"MCP_MADE_FOR_BIONIC", // legacy, documented selector (see README_MCP.md)
"MCP_MADE_FOR", // documented alternate selector for the unsloth/generic presets (see README_MCP.md)
"VISION_API", // overrides which adapter's model-loading methods to use; defaults to MCP_MADE_FOR's preset name
"VISION_API_BASE_URL",
"VISION_API_KEY",
"QWEN3_VL_MODEL",
"LLAMA_SERVER_BINARY", // only read by the VISION_API="llama-server" adapter
"LLAMA_SERVER_PORT", // only read by the VISION_API="llama-server" adapter
"LLAMA_SERVER_CTX_SIZE", // only read by the VISION_API="llama-server" adapter
"LLAMA_SERVER_MODELS_MAX", // only read by the VISION_API="llama-server" adapter
"LLAMA_SERVER_IDLE_TTL_MINUTES", // only read by the VISION_API="llama-server" adapter
"EMBED_PNG_METADATA",
"INCLUDE_GENERATION_METADATA",
"CLIENT_DB_LOCATION", // only read by the unsloth preset's beforeToolCall hook
"HTTP_SERVER_PORT",
];
/**
* 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() {
const snapshot = {};
for (const name of MCP_ENV_VAR_NAMES) {
snapshot[name] = process.env[name] ?? null;
}
return snapshot;
}
const VISION_API_NAMES = ["bionic", "generic", "unsloth", "llama-server"];
/** VISION_API overrides which adapter's model-loading methods to use; defaults to each preset's own PRESET_DEFAULTS.visionApi (NOT always preset.name — generic/unsloth now default to "llama-server", see PRESET_DEFAULTS). Falls back to the same default on an unset or unrecognized value — never throws. */
function resolveVisionApi(preset, raw) {
const trimmed = (raw ?? "").trim().toLowerCase();
return VISION_API_NAMES.includes(trimmed) ? trimmed : PRESET_DEFAULTS[preset.name].visionApi;
}
export function readMcpConfig() {
const preset = resolveMadeForEnv(optionalEnv("MCP_MADE_FOR"), optionalEnv("MCP_MADE_FOR_BIONIC"));
const defaults = PRESET_DEFAULTS[preset.name];
return {
chatWorkingDirectories: optionalEnv("CHAT_WORKING_DIRECTORIES") ?? defaults.chatWorkingDirectories,
preset,
visionApiBaseUrl: optionalEnv("VISION_API_BASE_URL") ?? defaults.visionApiBaseUrl,
visionApiKey: optionalEnv("VISION_API_KEY") ?? "",
qwen3VlModel: optionalEnv("QWEN3_VL_MODEL") ?? defaults.qwen3VlModel,
embedPngMetadata: parseBoolean(optionalEnv("EMBED_PNG_METADATA"), true),
includeGenerationMetadata: parseBoolean(optionalEnv("INCLUDE_GENERATION_METADATA"), true),
clientDbLocation: optionalEnv("CLIENT_DB_LOCATION") ?? path.join(os.homedir(), ".unsloth", "studio", "studio.db"),
visionApi: resolveVisionApi(preset, optionalEnv("VISION_API")),
llamaServerBinary: optionalEnv("LLAMA_SERVER_BINARY") ?? defaults.llamaServerBinary,
llamaServerPort: parseNumber(optionalEnv("LLAMA_SERVER_PORT"), LLAMA_SERVER_PORT_DEFAULT),
llamaServerCtxSize: parseNumber(optionalEnv("LLAMA_SERVER_CTX_SIZE"), LLAMA_SERVER_CTX_SIZE_DEFAULT),
llamaServerModelsMax: parseNumber(optionalEnv("LLAMA_SERVER_MODELS_MAX"), LLAMA_SERVER_MODELS_MAX_DEFAULT),
llamaServerIdleTtlMinutes: parseNumber(optionalEnv("LLAMA_SERVER_IDLE_TTL_MINUTES"), LLAMA_SERVER_IDLE_TTL_MINUTES_DEFAULT),
httpServerPort: parsePort("HTTP_SERVER_PORT", optionalEnv("HTTP_SERVER_PORT"), defaultHttpServerPort(preset)),
};
}
/** Sets the process.env values core/tools.ts reads directly (see handleAnalyseImage/handleDetectObject/handleAnnotateImage). */
export function applyMcpConfig(config) {
process.env.LMSTUDIO_VISION_API_BASE_URL = config.visionApiBaseUrl;
process.env.LMSTUDIO_VISION_API_KEY = config.visionApiKey;
process.env.LMSTUDIO_VISION_MODEL_KEY = config.qwen3VlModel;
process.env.EMBED_PNG_METADATA = config.embedPngMetadata ? "true" : "false";
process.env.INCLUDE_GENERATION_METADATA = config.includeGenerationMetadata ? "true" : "false";
process.env.LMSTUDIO_VISION_API = config.visionApi;
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);
}
export function logPreflight(line) {
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.
}
}