import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { z } from "zod"; const LLM_TIER_VALUES = ["standard", "safe", "creative"] as const; export type LlmTier = (typeof LLM_TIER_VALUES)[number]; export const DEFAULT_LLM_TIER: LlmTier = "standard"; export const DEFAULT_TELEGRAM_LLM_TIER: LlmTier = "safe"; export const DEFAULT_LLM_MAX_STEPS = 8; export const DEFAULT_LLM_TEMPERATURE = 0.4; export const MAX_LLM_MAX_STEPS = 32; const MAX_LLM_TEMPERATURE = 2; function sanitizeLlmTier(value: unknown): LlmTier | undefined { if (value === undefined) return undefined; if (typeof value !== "string") return DEFAULT_LLM_TIER; const normalized = value.trim().toLowerCase(); return LLM_TIER_VALUES.includes(normalized as LlmTier) ? (normalized as LlmTier) : DEFAULT_LLM_TIER; } function sanitizeBoundedNumber( value: unknown, fallback: number, min: number, max: number, integer: boolean, ): number | undefined { if (value === undefined || value === null) return undefined; if (typeof value === "string" && value.trim() === "") return undefined; const parsed = typeof value === "number" ? value : Number(String(value).trim()); if (!Number.isFinite(parsed)) return fallback; const bounded = Math.min(max, Math.max(min, parsed)); return integer ? Math.trunc(bounded) : bounded; } const LlmTierSchema = z.preprocess( sanitizeLlmTier, z.enum(LLM_TIER_VALUES).default(DEFAULT_LLM_TIER), ); const LlmMaxStepsSchema = z.preprocess( (value) => sanitizeBoundedNumber(value, DEFAULT_LLM_MAX_STEPS, 1, MAX_LLM_MAX_STEPS, true), z.number().int().min(1).max(MAX_LLM_MAX_STEPS).default(DEFAULT_LLM_MAX_STEPS), ); const LlmTemperatureSchema = z.preprocess( (value) => sanitizeBoundedNumber(value, DEFAULT_LLM_TEMPERATURE, 0, MAX_LLM_TEMPERATURE, false), z.number().min(0).max(MAX_LLM_TEMPERATURE).default(DEFAULT_LLM_TEMPERATURE), ); function csvList(value: unknown): string[] | undefined { if (value === undefined || value === null) return undefined; if (Array.isArray(value)) return value .map(String) .map((v) => v.trim()) .filter(Boolean); if (typeof value !== "string") return undefined; const trimmed = value.trim(); if (!trimmed) return undefined; return trimmed .split(",") .map((part) => part.trim()) .filter(Boolean); } const CsvListSchema = z.preprocess(csvList, z.array(z.string()).default([])); /** Coerces an env/file boolean-ish value: "1"/"true" (any case) → true, else → false. */ function ragEnabledFlag(value: unknown): boolean | undefined { if (value === undefined || value === null) return undefined; if (typeof value === "boolean") return value; if (typeof value !== "string") return undefined; const normalized = value.trim().toLowerCase(); if (normalized === "") return false; return normalized === "1" || normalized === "true"; } const RagEnabledSchema = z.preprocess(ragEnabledFlag, z.boolean().default(false)); /** * Centralized "boolean-ish env" parser for Creative RAG feature flags. Both * config.ts (parsed config) and call sites that read env BEFORE config is built * (tool registration, Layer 2 index) must agree on what counts as enabled — * accept "1" or "true" (case-insensitive), trim whitespace. Anything else → * disabled. Keep this in sync with `ragEnabledFlag` above. */ export function isRagFeatureFlagEnabled(value: string | undefined): boolean { if (value === undefined || value === null) return false; const normalized = String(value).trim().toLowerCase(); return normalized === "1" || normalized === "true"; } const RAG_INJECT_K_DEFAULT = 3; const RAG_INJECT_K_MAX = 5; const RAG_INJECT_TIMEOUT_MS_DEFAULT = 3000; const RAG_PROBE_TIMEOUT_MS_DEFAULT = 3000; const RAG_FUSION_K_DEFAULT = 60; const RAG_FUSION_K_MAX = 1000; const RagApplyCardSchema = z.preprocess(ragEnabledFlag, z.boolean().default(false)); const RagInjectAskSchema = z.preprocess(ragEnabledFlag, z.boolean().default(false)); const RagInjectKSchema = z.preprocess( (value) => sanitizeBoundedNumber(value, RAG_INJECT_K_DEFAULT, 1, RAG_INJECT_K_MAX, true), z.number().int().min(1).max(RAG_INJECT_K_MAX).default(RAG_INJECT_K_DEFAULT), ); const RagInjectTimeoutMsSchema = z.preprocess( (value) => sanitizeBoundedNumber(value, RAG_INJECT_TIMEOUT_MS_DEFAULT, 1, Number.MAX_SAFE_INTEGER, true), z.number().int().min(1).default(RAG_INJECT_TIMEOUT_MS_DEFAULT), ); const RagProbeTimeoutMsSchema = z.preprocess( (value) => sanitizeBoundedNumber(value, RAG_PROBE_TIMEOUT_MS_DEFAULT, 1, Number.MAX_SAFE_INTEGER, true), z.number().int().min(1).default(RAG_PROBE_TIMEOUT_MS_DEFAULT), ); const RagFusionSchema = z.preprocess(ragEnabledFlag, z.boolean().default(false)); const RagFusionKSchema = z.preprocess( (value) => sanitizeBoundedNumber(value, RAG_FUSION_K_DEFAULT, 1, RAG_FUSION_K_MAX, true), z.number().int().min(1).max(RAG_FUSION_K_MAX).default(RAG_FUSION_K_DEFAULT), ); /** * Project RAG composite-score weights schema (technical:license:freshness:reliability). * Accepts either a 4-number object or a "0.45:0.25:0.15:0.15" colon CSV from env. */ const ScoreWeightsSchema = z.object({ technical: z.number().min(0).max(1), license: z.number().min(0).max(1), freshness: z.number().min(0).max(1), reliability: z.number().min(0).max(1), }); function parseScoreWeights(value: unknown): unknown { if (value === undefined || value === null) return undefined; if (typeof value === "object") return value; if (typeof value !== "string") return undefined; const parts = value .split(":") .map((p) => p.trim()) .filter(Boolean) .map(Number); if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return undefined; const [technical, license, freshness, reliability] = parts as [number, number, number, number]; return { technical, license, freshness, reliability }; } function sanitizeTelegramTier(value: unknown): LlmTier | undefined { if (typeof value !== "string") return undefined; const normalized = value.trim().toLowerCase(); return LLM_TIER_VALUES.includes(normalized as LlmTier) ? (normalized as LlmTier) : undefined; } const TelegramTierSchema = z.preprocess( sanitizeTelegramTier, z.enum(LLM_TIER_VALUES).default(DEFAULT_TELEGRAM_LLM_TIER), ); export const ConfigSchema = z.object({ /** TouchDesigner bridge host. */ tdHost: z.string().min(1).default("127.0.0.1"), /** TouchDesigner bridge port (WebServer DAT). */ tdPort: z.coerce.number().int().positive().max(65535).default(9980), /** MCP transport: `stdio` (default, for local clients) or `http` (Streamable HTTP, loopback-only). */ transport: z.enum(["stdio", "http"]).default("stdio"), /** Log verbosity (written to stderr). */ logLevel: z.enum(["debug", "info", "warn", "error", "silent"]).default("info"), /** Per-request timeout against the TD bridge, in milliseconds. */ requestTimeoutMs: z.coerce.number().int().positive().default(10000), /** HTTP transport port (only used when transport=http). */ httpPort: z.coerce.number().int().positive().max(65535).default(3939), /** Subscribe to TD WebSocket events and forward them as MCP logging notifications. */ events: z.enum(["on", "off"]).default("on"), /** * Raw Python escape-hatch tools (`execute_python_script`, `exec_node_method`, * `create_python_script`). * Set to "off" to lock them out for restricted setups; on by default. */ rawPython: z.enum(["on", "off"]).default("on"), /** * "YOLO" mode (`TDMCP_YOLO=1`): skip any interactive confirmation the bridge may * add for destructive actions. No native dialogs exist yet, so today this only * flows into result reporting; off by default so nothing is silently skipped. */ yolo: z.preprocess(ragEnabledFlag, z.boolean().default(false)), /** * Tool exposure profile. `full` (default) registers every tool; `safe` * additionally hides the destructive/raw-code tools (a superset of * TDMCP_RAW_PYTHON=off) so an autonomous in-TD agent (e.g. via LOPs) gets a * curated, non-destructive surface. Default `full` keeps existing clients * unaffected. */ toolProfile: z.enum(["full", "safe"]).default("full"), /** * Optional shared bearer token for the TD bridge. When set, the server sends it * as `Authorization: Bearer ` and the bridge requires a match. Leave unset * (default) for the zero-config local flow. Set the SAME value in TouchDesigner's * environment (`TDMCP_BRIDGE_TOKEN`) to turn enforcement on. */ bridgeToken: z.string().min(1).optional(), /** * Optional bearer token that the Streamable HTTP transport requires on every * request (`Authorization: Bearer `). Acts as the enforcement half of an * MCP OAuth2 Resource Server: when set, missing/invalid credentials get a 401 with * a `WWW-Authenticate: Bearer` challenge. Unset (default) keeps the zero-config * local flow open. The HTTP transport binds loopback only, so this matters when * the server is fronted by a proxy or bound to a LAN interface. */ httpAuthToken: z.string().min(1).optional(), /** * Base URL of an OpenAI-compatible chat endpoint used by `tdmcp chat` (the local * LLM copilot). Defaults to Ollama's local server. Point it at LM Studio, a cloud * GPU, or any OpenAI-compatible API to swap the model without code changes. */ llmBaseUrl: z.string().min(1).default("http://127.0.0.1:11434/v1"), /** * Model id the local copilot asks for (must be pulled in the backend, e.g. * `ollama pull qwen2.5:3b`). Default is `qwen2.5:3b`: in benchmarking it matched * the 7B/14B at 100% tool-calling on the copilot's simple-task workload while being * ~2x faster and ; export type LlmRuntimeConfig = Pick; export type TdmcpConfig = ParsedConfig; export type LoadedTdmcpConfig = ParsedConfig; /** Options for {@link loadConfig}. File loading is opt-in (entry points pass `useFiles`). */ export interface LoadConfigOptions { /** Read a `tdmcp.json` / `.tdmcprc` / global config file (off by default so unit tests stay env-pure). */ useFiles?: boolean; /** Select a named profile from the config file's `profiles` map (errors if missing). */ profile?: string; /** Explicit config file path; overrides the search order when set. */ configPath?: string; /** Per-invocation overrides (CLI flags) — highest precedence. Undefined keys are ignored. */ overrides?: Partial>; /** Directory to search for cwd config files (defaults to process.cwd()). */ cwd?: string; } export interface ConfigProfileSummary { name: string; keys: string[]; } export interface ConfigProfileList { source?: string; profiles: ConfigProfileSummary[]; } /** A loaded config file: the base settings, any named profiles, and where it came from. */ interface ConfigFile { base: Record; profiles: Record>; source?: string; } /** Maps env vars to config keys (values may be undefined; pruned before merge). */ function envValues(env: NodeJS.ProcessEnv): Record { return { tdHost: env.TDMCP_TD_HOST, tdPort: env.TDMCP_TD_PORT, transport: env.TDMCP_TRANSPORT, logLevel: env.TDMCP_LOG_LEVEL, requestTimeoutMs: env.TDMCP_REQUEST_TIMEOUT_MS, httpPort: env.TDMCP_HTTP_PORT, events: env.TDMCP_EVENTS, rawPython: env.TDMCP_RAW_PYTHON, yolo: env.TDMCP_YOLO, toolProfile: env.TDMCP_TOOL_PROFILE, bridgeToken: env.TDMCP_BRIDGE_TOKEN || undefined, httpAuthToken: env.TDMCP_HTTP_AUTH_TOKEN || undefined, llmBaseUrl: env.TDMCP_LLM_BASE_URL, llmModel: env.TDMCP_LLM_MODEL, llmApiKey: env.TDMCP_LLM_API_KEY || undefined, llmTier: env.TDMCP_LLM_TIER || undefined, llmMaxSteps: env.TDMCP_LLM_MAX_STEPS || undefined, llmTemperature: env.TDMCP_LLM_TEMPERATURE || undefined, chatPort: env.TDMCP_CHAT_PORT, telegramBotToken: env.TDMCP_TELEGRAM_BOT_TOKEN || undefined, telegramAllowedChats: env.TDMCP_TELEGRAM_ALLOWED_CHATS || undefined, telegramAllowedUsers: env.TDMCP_TELEGRAM_ALLOWED_USERS || undefined, telegramDefaultTier: env.TDMCP_TELEGRAM_DEFAULT_TIER || undefined, telegramPollTimeoutSec: env.TDMCP_TELEGRAM_POLL_TIMEOUT_SEC || undefined, telegramConfirmTimeoutMs: env.TDMCP_TELEGRAM_CONFIRM_TIMEOUT_MS || undefined, vaultPath: env.TDMCP_VAULT_PATH || undefined, ragEnabled: env.TDMCP_RAG_ENABLED, ragDataDir: env.TDMCP_RAG_DATA_DIR || undefined, ragOllamaUrl: env.TDMCP_RAG_OLLAMA_URL || undefined, ragEmbedModel: env.TDMCP_RAG_EMBED_MODEL || undefined, ragLicenseAllowlist: env.TDMCP_RAG_LICENSE_ALLOWLIST || undefined, ragEmbedBatch: env.TDMCP_RAG_EMBED_BATCH || undefined, ragBackend: env.TDMCP_RAG_BACKEND || undefined, ragSmithsonianKey: env.TDMCP_RAG_SMITHSONIAN_KEY || undefined, ragEuropeanaKey: env.TDMCP_RAG_EUROPEANA_KEY || undefined, ragApplyCard: env.TDMCP_RAG_APPLY_CARD, ragInjectAsk: env.TDMCP_RAG_INJECT_ASK, ragInjectK: env.TDMCP_RAG_INJECT_K || undefined, ragInjectTimeoutMs: env.TDMCP_RAG_INJECT_TIMEOUT_MS || undefined, ragProbeTimeoutMs: env.TDMCP_RAG_PROBE_TIMEOUT_MS || undefined, ragFusion: env.TDMCP_RAG_FUSION, ragFusionK: env.TDMCP_RAG_FUSION_K || undefined, projectRagEnabled: env.TDMCP_PROJECT_RAG_ENABLED, projectRagBridgeAnalysis: env.TDMCP_PROJECT_RAG_BRIDGE_ANALYSIS, projectRagBridgePort: env.TDMCP_PROJECT_RAG_BRIDGE_PORT || undefined, projectRagGhToken: env.TDMCP_PROJECT_RAG_GH_TOKEN || undefined, projectRagGithubRepos: env.TDMCP_PROJECT_RAG_GITHUB_REPOS || undefined, projectRagGithubTopics: env.TDMCP_PROJECT_RAG_GITHUB_TOPICS || undefined, projectRagTopicCap: env.TDMCP_PROJECT_RAG_TOPIC_CAP || undefined, projectRagDerivativeRoot: env.TDMCP_PROJECT_RAG_DERIVATIVE_ROOT || undefined, projectRagIihq: env.TDMCP_PROJECT_RAG_IIHQ, projectRagIihqRef: env.TDMCP_PROJECT_RAG_IIHQ_REF || undefined, projectRagAnalyzeTimeoutMs: env.TDMCP_PROJECT_RAG_ANALYZE_TIMEOUT_MS || undefined, projectRagLicenseAllowlist: env.TDMCP_PROJECT_RAG_LICENSE_ALLOWLIST || undefined, projectRagScoreWeights: env.TDMCP_PROJECT_RAG_SCORE_WEIGHTS || undefined, }; } /** Drop keys whose value is `undefined` so they don't clobber a lower-precedence layer. */ function pruneUndefined(obj: Record): Record { return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)); } /** Candidate config-file paths in precedence order (first existing wins). */ function configSearchPaths(env: NodeJS.ProcessEnv, cwd: string): string[] { const explicit = env.TDMCP_CONFIG_FILE?.trim(); if (explicit) return [explicit]; const globalDir = env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config"); return [join(cwd, "tdmcp.json"), join(cwd, ".tdmcprc"), join(globalDir, "tdmcp", "config.json")]; } /** * Reads the first existing config file (or `configPath` when given). Fail-safe: a * missing file yields empty config; a malformed file warns to stderr and is ignored, * never throwing — a broken config must not take the server/CLI down. */ function readConfigFile(env: NodeJS.ProcessEnv, opts: LoadConfigOptions): ConfigFile { const cwd = opts.cwd ?? process.cwd(); const candidates = opts.configPath ? [opts.configPath] : configSearchPaths(env, cwd); for (const path of candidates) { if (!existsSync(path)) continue; try { const raw = JSON.parse(readFileSync(path, "utf8")) as Record; const { profiles, ...base } = raw; const profileMap = profiles && typeof profiles === "object" ? (profiles as Record>) : {}; return { base, profiles: profileMap, source: path }; } catch (err) { process.stderr.write( `tdmcp: ignoring malformed config file ${path}: ${(err as Error).message}\n`, ); return { base: {}, profiles: {} }; } } return { base: {}, profiles: {} }; } /** Lists named profiles from the selected config file without exposing their values. */ export function listConfigProfiles( env: NodeJS.ProcessEnv = process.env, opts: LoadConfigOptions = {}, ): ConfigProfileList { const file = readConfigFile(env, { ...opts, useFiles: true }); const profiles = Object.entries(file.profiles) .map(([name, values]) => ({ name, keys: Object.keys(values).sort(), })) .sort((a, b) => a.name.localeCompare(b.name)); return { source: file.source, profiles }; } /** * Loads and validates configuration. By default reads **environment variables only** * (missing values fall back to defaults; invalid values throw a descriptive ZodError), * which keeps the bare `loadConfig()` deterministic for tests and existing callers. * * Entry points opt into config files with `{ useFiles: true }`. Precedence, lowest → * highest: schema defaults < file base < file profile (`{ profile }`) < environment < * CLI `{ overrides }`. So an artist can save per-venue setups in `tdmcp.json` * (`{ profiles: { club: { tdHost, tdPort } } }`) and switch with `--profile club`, * while env vars and one-off flags still win. */ export function loadConfig( env: NodeJS.ProcessEnv = process.env, opts: LoadConfigOptions = {}, ): LoadedTdmcpConfig { const file = opts.useFiles ? readConfigFile(env, opts) : { base: {}, profiles: {} }; const profileName = opts.profile ?? (opts.useFiles ? env.TDMCP_PROFILE : undefined); let profilePart: Record = {}; if (profileName) { const found = (file as ConfigFile).profiles?.[profileName]; if (!found) { const where = (file as ConfigFile).source ? ` (${(file as ConfigFile).source})` : ""; throw new Error( `Config profile "${profileName}" not found${where}. Define it under "profiles" in your config file.`, ); } profilePart = found; } const merged = { ...file.base, ...profilePart, ...pruneUndefined(envValues(env)), ...pruneUndefined(opts.overrides ?? {}), }; return ConfigSchema.parse(merged); } /** Sensitive keys redacted by {@link describeConfig} for safe printing/sharing. */ const SECRET_KEYS: ReadonlyArray = [ "bridgeToken", "httpAuthToken", "llmApiKey", "telegramBotToken", "telegramAllowedChats", "telegramAllowedUsers", "ragSmithsonianKey", "ragEuropeanaKey", "projectRagGhToken", ]; /** A copy of the config safe to print/share — secrets are masked. */ export function describeConfig(config: TdmcpConfig | LoadedTdmcpConfig): Record { const out: Record = { ...config }; for (const key of SECRET_KEYS) { if (out[key] !== undefined) out[key] = "***redacted***"; } return out; } /** Base URL for the TouchDesigner REST bridge. */ export function tdBaseUrl(config: Pick): string { return `http://${config.tdHost}:${config.tdPort}`; }